diff --git a/.env.example b/.env.example deleted file mode 100644 index 650a92b..0000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -QUEUE_CONFIG_PATH=/.config/queue.yaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 001f0fa..b992343 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .vscode -.config \ No newline at end of file +.omc \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d508a7f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# Matchmaker + +Postgres 기반 minimalistic 매치메이커 OSS. Open Match 대안. + +## 핵심 원칙 + +- **의존성은 Postgres만** — Redis, Kafka, NATS 등 외부 큐/캐시 없음 +- **Polling 기반 통신** — streaming 회피, 운영 단순성 우선 +- **Config 런타임 변경** — 재배포 없이 라이브 튜닝 (API 기반) +- **Allocator-agnostic** — Agones/GameLift/PlayFab 등 모두 통합 가능 +- **설치형 배포** — Helm chart 단일 명령으로 설치 + +## 컴포넌트 + +- **API Gateway** — gRPC API, stateless, 큐/풀 설정 무지 (단순 transport) +- **Director** — 1초 cycle, Entity Store batch pull, pool 분류, ME로 dispatch (1 active + standby) +- **MatchingEngine (ME)** — pool당 1개, 후보 매치 생성, lazy config fetch +- **Evaluator** — 후보 aggregation, 충돌 해결, 최종 매치 확정 (1 active + standby) +- **Config Syncer** — Config Store polling 캐싱, 컴포넌트에 config API 제공 +- **Entity Store** — Postgres (tickets, matches) +- **Config Store** — Postgres (queues, pools, rules, history) — runtime DB와 분리 + +## 통신 경계 + +**외부 시스템(Game Backend, External DGS Allocator)은 오직 API Gateway를 통해서만 매치메이커와 통신한다.** Entity Store, Director, ME, Evaluator 등 내부 컴포넌트는 외부에 노출되지 않는다. + +## 데이터 흐름 + +``` +[외부 경계] +Client ──→ Game Backend ──┐ + ├──gRPC──→ API Gateway ──┐ +External DGS Allocator ───┘ │ + ↑ ClaimMatches/Report │ + ↓ +[내부 컴포넌트] Entity Store + ↑ polling + Director ← Config Syncer ← Config Store + ↓ dispatch (with config_version) + Matching Engine × N + ↓ candidates + Evaluator + ↓ matches insert + Entity Store + +[게임 서버 할당] +External DGS Allocator ──→ DGS Fleet (게임 서버 시작) + ──→ API Gateway (ReportAssignment) +``` + +외부 통신 경로 (모두 API Gateway 경유): + +- **Game Backend → Gateway**: CreateTicket, GetTicket (매칭 결과 polling) +- **DGS Allocator → Gateway**: ClaimMatches (polling), ReportAssignment, ReleaseMatch +- **Gateway → 외부**: 없음 (Pull-only) + +## 코드 컨벤션 + +- Go 1.22+ +- gRPC 인터페이스는 `proto/` 하위 정의 +- DB 접근은 pgx 사용 +- CEL 룰 평가는 cel-go +- 컴포넌트 간 통신은 gRPC unary RPC (streaming 지양) + +## 부하 기준 + +목표: 동접 100만, ~600 RPS (CreateTicket 기준) +- Director polling: 1 QPS +- Evaluator → matches insert: ~139 QPS +- DGS Allocator polling: 1 QPS +- 단일 Postgres가 압도적으로 여유 있게 처리 + +## 디렉토리 + +``` +matchmaker/ +├── proto/ # gRPC 정의 +├── services/ # 매치메이커 본체 +│ ├── gateway/ +│ ├── director/ +│ ├── engine/ +│ ├── evaluator/ +│ └── syncer/ +├── helm/ # Helm chart +├── examples/ +│ ├── agones-adapter/ +│ └── kubernetes-adapter/ +└── docs/ + ├── design/ # 컴포넌트별 상세 + ├── decisions/ # ADR + └── design-discussion.md # 초기 토론 전문 +``` + +## 상세 문서 + +설계 상세: `docs/design/` +의사결정 이력: `docs/decisions/` (ADR 형식) +초기 토론: `docs/design-discussion.md` diff --git a/README.md b/README.md index 87469cd..6d5fb98 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,11 @@ -# 🎮 matchmaker +# Matchmaker +A minimalistic self hosted matchmaker for multiplayer games. -A lightweight and high-performance matchmaker service written in Go. -Designed for real-time game matchmaking with concurrent player support. +Supports 1M concurrent players. -# Components -## API Server -The API server provides a RESTful interface for managing matchmaking tickets, match candidates, player acknowledgements, and game results. +## System Design +![matchmaker system design](docs/design/matchmaker_0523rev3.png) -### REST API Endpoints - -Below is a summary of the REST API endpoints and their purposes: - -| Description | HTTP Method | Endpoint | -|--------------------------------------------|-------------|-----------------------------------------| -| Create matchmaking ticket | POST | `/tickets` | -| Cancel matchmaking ticket | DELETE | `/tickets/{ticket_id}` | -| List current match candidates | GET | `/matches/candidates` | -| Create or update player acknowledgement | PUT | `/matches/{match_id}/acknowledgement` | -| Submit game result (win/loss) | POST | `/match-results` | +## Features +- Backfill +- \ No newline at end of file diff --git a/architecture.md b/architecture.md deleted file mode 100644 index fcb534c..0000000 --- a/architecture.md +++ /dev/null @@ -1,21 +0,0 @@ -## Architecture - -```mermaid -flowchart LR - A[API Server] - B@{shape: das, label: "ticket"} - C@{shape: das, label: "match"} - D@{shape: cyl, label: "Feature Sture"} - E[Loader] - F[MatchFinder] - G[Redis] - - A --request ticket--> B - B -->E - D --features--> E - E --ticket with feature--> G - G --periodic fetch--> F - F --match candidates--> C - C --match response-->A - A --game result-->D -``` \ No newline at end of file diff --git a/cmd/apiserver/main.go b/cmd/apiserver/main.go deleted file mode 100644 index e019426..0000000 --- a/cmd/apiserver/main.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "log/slog" - "os" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/apiserver" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase" - "github.com/chaewonkong/matchmaker/services/queue" - "github.com/labstack/echo/v4" -) - -const ( - // ExitCodeSuccess success - ExitCodeSuccess = 0 - // ExitCodeFailure failure - ExitCodeFailure = 1 -) - -func main() { - code := run() - os.Exit(code) -} - -func run() int { - queueConfigPath := os.Getenv("QUEUE_CONFIG_PATH") - - queueConfig := schema.NewQueueConfig() - err := queueConfig.UnmarshalFromYAML(queueConfigPath) - if err != nil { - slog.Error("failed to load queue config", "error", err) - return ExitCodeFailure - } - - logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) - logger.Info("starting the application...") - - e := echo.New() - q := queue.New() - ts := usecase.NewTicketService(q) - ms, err := usecase.NewMatchService(queueConfig, q) - if err != nil { - logger.Error("failed to create match service", "error", err) - } - h := apiserver.NewHandler(ts, ms) - - // middlewares etc - e.Validator = apiserver.NewCustomValidator() - - apiserver.RegisterRoutes(e, h) - - err = e.Start(":8080") - if err != nil { - logger.Error("failed to start the server", "error", err) - return ExitCodeFailure - } - - return ExitCodeSuccess -} diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go deleted file mode 100644 index 00b8b2d..0000000 --- a/cmd/simulator/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import "log" - -func main() { - log.Fatal("not ready") - - // 1. create_ticket - // 2. delete_ticket on random ratio - // 3. get and ack events -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 1944d72..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - redis: - image: redis:latest - ports: - - 6379:6379 - volumes: - - redis-data:/data - - redis-data:/usr/local/conf/redis.conf - labels: - - 'name=redis' - - 'mode=standalone' - restart: always - command: redis-server /usr/local/conf/redis.conf - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 10s - timeout: 5s - retries: 3 - api-server: - image: api-server:latest - -volumes: - redis-data: \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 2fe6b40..0000000 --- a/docs/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Generate API Documentation html - -```bash -aglio -i matchmaking.apib --theme-template triple -o index.html -``` \ No newline at end of file diff --git a/docs/decisions/001-postgres-only.md b/docs/decisions/001-postgres-only.md new file mode 100644 index 0000000..c03ec40 --- /dev/null +++ b/docs/decisions/001-postgres-only.md @@ -0,0 +1,79 @@ +# ADR-001: Postgres-only 의존성 + +## Status + +Accepted (2026-05-23) + +## Context + +매치메이커는 다음과 같은 데이터 처리가 필요하다: + +- Ticket 영속화 및 lifecycle 관리 +- 매칭 워커의 분산 컨슈머 패턴 +- 매치 결과 저장 +- 설정 (큐/풀/룰) 저장 +- 컴포넌트 간 이벤트 통보 + +전통적으로 이런 시스템은 Redis (캐시/큐), Kafka/NATS (메시지 큐), Postgres (영속화)를 조합해서 구현한다. Open Match도 Redis를 핵심 의존성으로 사용한다. + +설치형 OSS 도구로 배포할 경우, 사용자(게임사)가 직접 운영해야 한다. 의존성이 많을수록 도입 장벽과 운영 부담이 커진다. + +## Decision + +**외부 큐/캐시 의존성 없이 Postgres만 사용한다.** + +- 분산 컨슈머 패턴: `SELECT ... FOR UPDATE SKIP LOCKED` +- 이벤트 통보: `LISTEN/NOTIFY` (선택적, polling이 기본) +- JSONB로 동적 attribute 저장 +- 트랜잭션으로 복잡한 상태 전이 일관성 보장 + +단, runtime DB와 config DB는 분리 (ADR-006 참조). + +## Consequences + +### 장점 + +- **운영 부담 최소**: 사용자가 Postgres 하나만 관리하면 됨 +- **Helm chart 단순**: 의존성 명세가 짧음 +- **트랜잭션 일관성 자연스러움**: 매치 lifecycle의 복잡한 상태 전이를 native transaction으로 처리 +- **JSONB로 동적 attribute 표현 가능**: 큐별 커스텀 schema 지원 +- **운영자 친화적 디버깅**: SQL로 모든 상태 조회 가능 + +### 단점 + +- **Throughput 상한 존재**: 단일 Postgres는 결국 수만 QPS가 한계 +- **Multi-region 시 복잡**: Logical replication 직접 설계 필요 +- **GIN 인덱스의 range query 한계**: JSONB attribute의 SQL 쿼리 성능 제한 + +### 부하 검증 + +목표 100만 동접 + 600 RPS 환경에서 측정한 부하: + +- CreateTicket: ~556 QPS +- Match insert: ~139 QPS +- 나머지 컴포넌트 polling: 1자리 QPS + +단일 Postgres가 압도적으로 여유 있게 처리하는 영역. + +## Alternatives Considered + +### Redis Streams (Open Match 방식) + +- 장점: 빠른 throughput, 분산 컨슈머 패턴 (XREADGROUP) +- 단점: 복잡한 상태 전이에 약함 (백필 등), 의존성 추가, Lua 스크립트 필요 + +### Kafka/NATS 등 메시지 큐 + +- 장점: 확실한 분리, 백프레셔 표현 +- 단점: 트랜잭션 일관성 어려움, 운영 부담 큼 + +### Postgres + Redis 하이브리드 + +- 장점: 각자 잘하는 곳에 사용 +- 단점: 두 시스템의 일관성 관리, 운영 부담 + +## Notes + +부하 측면에서는 Redis가 빠르지만, 매치메이커는 **상태 전이가 많은 도메인**(ticket lifecycle, match lifecycle, 백필, 취소, 타임아웃)이므로 transaction이 일급 시민인 Postgres가 적합하다. + +매치메이커의 본질적 어려움은 throughput이 아니라 매칭 품질, fairness, 다운스트림 통합에 있다 (ADR-003 참조). diff --git a/docs/decisions/002-polling-over-streaming.md b/docs/decisions/002-polling-over-streaming.md new file mode 100644 index 0000000..99f4e9c --- /dev/null +++ b/docs/decisions/002-polling-over-streaming.md @@ -0,0 +1,87 @@ +# ADR-002: Polling 기반 통신 채택 + +## Status + +Accepted (2026-05-23) + +## Context + +매치메이커 시스템의 통신 경로: + +- 외부 → 매치메이커: 게임 백엔드의 ticket 생성, DGS Allocator의 매치 조회 +- 매치메이커 내부: Director ↔ Entity Store, Config Syncer ↔ Config Store, Syncer ↔ 각 컴포넌트 +- 매치메이커 → 외부: 없음 (Pull-only API, ADR-007 참조) + +각 경로마다 gRPC streaming과 polling 사이 선택지가 있다. + +## Decision + +**외부 인터페이스 및 컴포넌트 간 통신의 기본은 polling으로 한다.** + +구체적으로: + +- 외부 → 매치메이커: gRPC unary RPC polling (1초 또는 그 이상) +- Director → Entity Store: SQL polling (1초 cycle) +- Config Syncer → Config Store: SQL polling (5~10초) +- 각 컴포넌트 → Config Syncer: polling 또는 Director가 dispatch에 config_version 명시 (ADR-005 참조) + +Streaming은 도입하지 않는다. + +## Consequences + +### 장점 + +- **로드밸런서 단순**: 표준 HTTP/2 unary RPC라 일반 L4/L7 LB로 충분 +- **Keep-alive 부담 없음**: long-lived connection 관리 불필요 +- **재시도 시맨틱 자명**: 각 요청 독립, 그냥 다시 요청하면 됨 +- **장애 복구 자동**: 일시 단절 후 다음 cycle에 자연스럽게 catch-up +- **디버깅 단순**: 각 요청을 독립적으로 추적 가능 +- **운영자 mental model 단순**: "N초마다 본다"는 한 줄로 설명 + +### 단점 + +- **Latency**: 평균 `interval/2`, 최악 `interval`의 지연 +- **불필요한 query**: 변경 없어도 매 cycle 조회 + +### Latency 검증 + +- Director cycle: 1초 → 새 ticket이 매칭 시작까지 평균 0.5초 +- Config polling: 5~10초 → 운영자 변경 반영까지 최대 ~10초 +- 실제 매치 대기 시간이 수십 초~분 단위이므로 무시 가능 + +## Alternatives Considered + +### gRPC streaming + +- 장점: 낮은 latency (~수십 ms), 변경 즉시 전파 +- 단점: + - L4 LB의 connection 균등 분배 깨짐 → L7 (Envoy, gRPC-LB) 필요 + - HTTP/2 ping, TCP keep-alive, 프록시 idle timeout 모두 정렬 필요 + - Connection 재분배 (Gateway 추가 시 rebalancing) + - 재시도 시맨틱 모호 (at-least-once vs at-most-once) + - 조용한 connection drop 디버깅 어려움 +- 설치형 OSS에서 사용자 운영 부담이 너무 큼 + +### LISTEN/NOTIFY + +- Postgres 변경을 즉시 알림 받음 +- 매치메이커 내부 (Syncer ↔ Config Store)에서는 보강으로 사용 가능 +- 단, drop 가능성 있어 polling fallback 필요 +- 컴포넌트 ↔ Syncer 사이는 polling이 더 단순 + +### Long polling + +- 중간 지점이지만 단순 polling으로 부하 충분히 처리되므로 도입 가치 낮음 + +## Notes + +게임 매칭 도메인의 시간 단위: + +- Director 매칭 cycle: 1초 +- 일반적인 매치 대기: 수 초 ~ 1~2분 +- 운영자 룰 변경 → 효과 관찰: 분 단위 +- MMR 분포 변화: 시간 ~ 일 단위 + +Polling 모델이 실시간성보다 우월하다고 판단할 근거가 없다. **시스템 전체의 자연 시간 단위가 polling과 잘 맞는다**. + +부하 측면에서도 Director/Allocator/Syncer 모두 1자리 QPS 수준이므로 polling 비용 무시 가능. diff --git a/docs/decisions/003-gateway-config-agnostic.md b/docs/decisions/003-gateway-config-agnostic.md new file mode 100644 index 0000000..e01e1d5 --- /dev/null +++ b/docs/decisions/003-gateway-config-agnostic.md @@ -0,0 +1,104 @@ +# ADR-003: API Gateway는 유일한 외부 진입점이자 큐/풀 설정 무지 + +## Status + +Accepted (2026-05-23) + +## Context + +매치메이커의 외부 진입점은 API Gateway다. 두 가지 결정이 얽혀 있다: + +**결정 1: 외부 진입점을 단일화할 것인가?** + +매치메이커의 외부 시스템(Game Backend, External DGS Allocator)이 어떤 컴포넌트와 통신할 수 있는지 정의해야 한다. 옵션: + +1. 외부가 Entity Store, Director 등 내부 컴포넌트를 직접 호출 +2. 외부는 API Gateway만 통하고, 내부 컴포넌트는 외부에 노출되지 않음 + +**결정 2: Gateway가 어떤 책임을 가지는가?** + +1. Gateway가 모든 비즈니스 로직 수행: 인증, 검증, attribute schema 체크, pool 분류, DB INSERT +2. Gateway는 transport만: 받아서 다음 컴포넌트로 넘기는 역할만 + +두 결정 모두 부하나 성능이 아닌 **bounded context 분리**의 문제다. + +## Decision + +**API Gateway는 매치메이커의 유일한 외부 진입점이며, 큐/풀 설정을 모르는 단순 transport 역할만 한다.** + +### 외부 진입점 단일화 + +- Game Backend, External DGS Allocator 등 외부 시스템은 **오직 API Gateway를 통해서만** 매치메이커와 통신한다 +- Entity Store, Director, MatchingEngine, Evaluator, Config Syncer 등 내부 컴포넌트는 **외부에 노출되지 않는다** +- Gateway가 외부에 노출하는 API: + - `CreateTicket` (Game Backend → Gateway) + - `GetTicket` (Game Backend → Gateway, 매칭 결과 polling) + - `ClaimMatches` (DGS Allocator → Gateway) + - `ReportAssignment` (DGS Allocator → Gateway) + - `ReleaseMatch` (DGS Allocator → Gateway) + +### Gateway의 큐/풀 무지 + +Gateway가 알아야 할 것 vs 몰라도 되는 것: + +| Gateway가 알아야 함 | Gateway가 몰라도 됨 | +|---|---| +| RPC schema (gRPC proto) | Pool 정의 | +| Queue ID (라우팅용) | Pool 분류 룰 | +| 인증 정보 | Attribute schema | +| Ticket의 raw payload | CEL 룰 | +| Entity Store INSERT/SELECT 방법 | Matching 알고리즘 | + +Gateway는 받은 ticket을 그대로 Entity Store에 INSERT만 한다. 분류는 Director가 책임진다. ClaimMatches 등 매치 dequeue도 Gateway가 Entity Store에 SQL 쿼리로 수행한다. + +## Consequences + +### 장점 + +**외부 진입점 단일화 효과**: + +- **보안 경계 명확** — 내부 컴포넌트가 외부에 노출되지 않음, attack surface 최소화 +- **인증/인가 단일 지점** — Gateway에서만 인증 처리, 내부 통신은 mTLS 등 단순화 +- **rate limit / quota 단일 지점** — Gateway에서 전사적 정책 적용 +- **관제 단일 지점** — 외부 트래픽 메트릭이 Gateway 하나에 집중 +- **내부 리팩토링 자유** — Entity Store 스키마 변경 등이 외부 API에 영향 없음 + +**Gateway 큐/풀 무지 효과**: + +- **큐/풀 설정 변경 시 Gateway 재배포 불필요** — Director만 hot-reload +- **Gateway가 진짜 stateless + dumb** — CEL 런타임, 룰 캐시 불필요, footprint 작음, 시작 빠름, 수평 확장 자유 +- **멀티 테넌시 자연스러움** — 여러 게임 모드/큐가 한 인스턴스에 공존해도 Gateway는 모드별 차이 무지 +- **비즈니스 로직 격리** — 외부 노출 면에 매칭 룰이 reverse engineering될 단서 없음 +- **설정 검증의 단일 소유자** — Director 또는 Config Service에 집중 + +### 단점 + +- **추가 컴포넌트 필요** — Pool 분류 책임이 별도 컴포넌트(Director)로 이동 +- **운영 시나리오에 따라 복잡도가 다른 곳으로 이동** — Gateway가 단순해진 대가로 Director가 무거워짐 + +## Alternatives Considered + +### Gateway가 직접 분류 후 INSERT + +- 장점: 컴포넌트 수 적음, write 한 번 +- 단점: + - 큐 설정 변경 시 Gateway 재배포 필요 + - Gateway가 stateful (CEL 룰 캐시) + - 외부 노출 면에 비즈니스 로직 노출 + - 멀티 테넌시 시 복잡 + +### Inbox 테이블을 두고 Pool Assigner가 별도 처리 + +검토했으나 단일 테이블 + 상태 머신으로 표현 가능하므로 over-engineering. + +## Notes + +이 결정의 핵심 가치는 **외부 노출 면을 단순하게 유지하고 내부 복잡도를 응집된 컴포넌트에 모으는 것**이다. + +Gateway가 단순해진 결과: +1. 신규 큐 추가 시 Gateway 무관 +2. 매칭 룰 튜닝 시 Gateway 무관 +3. 게임 추가 (멀티 테넌트) 시 Gateway는 game_id 라우팅만 +4. 긴급 큐 정지 시 Director가 drain, Gateway는 여전히 수신 + +운영 시나리오 대부분이 Gateway 무지의 이점을 누린다. diff --git a/docs/decisions/004-director-push-model.md b/docs/decisions/004-director-push-model.md new file mode 100644 index 0000000..0cea3c7 --- /dev/null +++ b/docs/decisions/004-director-push-model.md @@ -0,0 +1,87 @@ +# ADR-004: Director Push 모델 채택 (Pool 멤버십을 메모리로) + +## Status + +Accepted (2026-05-23) + +## Context + +한 큐에 여러 pool이 있고 (Pool 범위 overlap), 한 ticket이 여러 pool에 동시 속할 수 있다. MatchingEngine은 pool당 1개씩 동작한다. + +Pool 멤버십을 어디에 저장할지 두 가지 선택지: + +### Pull 모델 (DB-centric) + +- Pool 멤버십을 DB에 영속화 (`ticket_pool_membership` 테이블 또는 generated column) +- ME가 SQL로 자기 pool ticket 조회 (SKIP LOCKED) + +### Push 모델 (Director-centric) + +- Director가 batch pull → 메모리에서 pool 분류 → ME로 직접 push +- Pool 멤버십이 Director의 in-memory 상태 + +## Decision + +**Push 모델 채택. Director가 분류 권위자 역할.** + +흐름: + +1. Ticket은 Entity Store에 영속화 (state = `created`) +2. Director가 1초 cycle로 batch pull (SKIP LOCKED, state → `in_director`) +3. Director가 메모리에서 pool 분류 +4. Director가 ticket + pool_id + config_version을 ME로 push (gRPC) +5. ME가 매칭 후보 생성 +6. Evaluator가 후보 수집 → 충돌 해결 → 최종 매치 확정 + +## Consequences + +### 장점 + +- **JSONB 인덱스 문제 해소** — ME가 SQL로 attribute 쿼리할 필요 없음 +- **Config version 전파 자연스러움** — Director가 권위 발급자, 일관성 보장 (ADR-005 참조) +- **Pool 정의 변경 시 즉시 반영** — Director 메모리만 갱신 +- **ticket_pool_membership 테이블 불필요** — write 감소 +- **ME는 stateless에 가까움** — 받은 ticket batch만 처리 + +### 단점 + +- **Director가 SPOF에 가까워짐** — single active + standby 필요 +- **Push 모델의 coordination 복잡도** — ME discovery, health check, fan-out +- **ME 가용성 처리가 Director 책임** — 재dispatch 로직 필요 +- **분산 컨슈머 패턴의 자연스러움 상실** — SKIP LOCKED 패턴 일부만 활용 + +### Lease 패턴 + +Director가 batch pull 시 `state = 'in_director'`로 변경하고 `leased_at`을 기록한다. Director 죽으면 별도 reaper가 stale lease를 `created`로 복원한다 (SQS visibility timeout과 동일한 패턴). + +## Alternatives Considered + +### Pull 모델 + DB에 영속화된 pool 멤버십 + +- 장점: ME가 자기 페이스로 동작, 단순한 분산 컨슈머, 운영자 친화적 디버깅 +- 단점: + - JSONB 인덱스로 동적 attribute 쿼리하는 문제 + - Pool 정의 변경 시 멤버십 재계산 + - Config version 전파 메커니즘 별도 필요 + - 컴포넌트 간 config 버전 불일치 가능 + +### Hybrid (DB에 ticket lifecycle + 메모리에 pool 멤버십) + +채택한 모델이 이것에 해당. Director가 ticket lifecycle 상태는 DB에 기록하고, pool 분류만 메모리에서 처리. + +## Notes + +이 결정은 Open Match의 Director 패턴과 유사하지만 차이가 있다: + +- Open Match: Director가 외부 사용자 구현 (Backend API 호출) +- 본 설계: Director가 내장 컴포넌트 (사용자는 CEL 룰만 정의) + +내장하는 대신 외부 통합 부담을 매우 낮추는 trade-off. + +부하 검증 (100만 동접 기준): + +- 매치 생성률 ~139 match/s +- Director cycle 1초마다 batch pull (limit 500~1000) +- 단일 Director가 압도적으로 여유 있게 처리 + +향후 부하가 더 커지면 큐별로 Director 분리 가능 (multi-Director). diff --git a/docs/decisions/005-config-runtime-mutable.md b/docs/decisions/005-config-runtime-mutable.md new file mode 100644 index 0000000..d71ea0f --- /dev/null +++ b/docs/decisions/005-config-runtime-mutable.md @@ -0,0 +1,85 @@ +# ADR-005: Config 런타임 변경 가능 (DB 기반, GitOps 불채택) + +## Status + +Accepted (2026-05-23) + +## Context + +큐/풀/매칭 룰 설정의 저장 및 변경 메커니즘을 결정해야 한다. + +선택지: + +1. **GitOps 방식**: YAML 파일을 Git으로 관리, Helm/ConfigMap으로 배포, 변경 시 재배포 +2. **DB 기반 런타임 변경**: API로 설정 변경, 즉시 반영 + +일반적인 OSS 인프라 도구(Prometheus, Argo Workflows 등)는 GitOps를 선호한다. 그러나 라이브 운영 게임의 매치메이커는 다른 특성을 가진다. + +## Decision + +**DB 기반 런타임 변경 API를 채택한다. GitOps는 부적합.** + +- 설정은 별도 Postgres (Config Store, ADR-006)에 저장 +- 운영자가 Config API로 변경 → 즉시 반영 +- Config 변경 이력은 `config_history` 테이블에 보관 (audit, rollback) +- 변경은 atomic transaction + version 단조 증가 +- Syncer가 polling으로 변경 감지 (ADR-002) + +## Consequences + +### 장점 + +- **라이브 튜닝 가능** — MMR 범위, 매칭 룰 등을 분 단위로 조정 +- **롤백 자연스러움** — 이전 version으로 즉시 복원 +- **canary 적용 가능** — 한 큐에만 먼저 적용 후 확장 +- **재배포 사이클 회피** — 게임 서버 무중단 운영과 호환 + +### 단점 + +- **GUI/API 실수 위험** — 잘못된 룰이 즉시 운영에 영향 → 검증 단계 필요 +- **버전 관리가 Git에 비해 약함** — config_history 테이블로 보강 +- **외부 도구 통합 어려움** — Git 기반 워크플로우와 자연스럽지 않음 + +### 필수 검증 단계 + +운영자의 룰 변경 시 Config API는 다음 검증을 수행: + +1. **문법 검증** — CEL 파싱 +2. **타입 검증** — referenced attribute가 schema에 있는지 +3. **시맨틱 검증** — dry-run으로 sample ticket에 적용 +4. **canary 옵션** — 한 큐에만 먼저 적용 +5. **atomic write + version 증가** + +특히 dry-run과 canary는 라이브 운영의 안전성에 결정적. + +## Alternatives Considered + +### GitOps (YAML + Helm ConfigMap) + +검토했으나 다음 이유로 부적합: + +**이유 1: 큐 설정과 게임 서버 파라미터의 바인딩** + +큐 설정은 게임 서버가 보내야 할 파라미터에 바인딩되는 경우가 많다. 큐 설정 롤백 시 게임 서버도 롤백해야 하는데, 게임 서버는 무중단 롤백이 현실적으로 어렵다. 한 번 적용한 설정의 롤백이 *기술적으로 가능해야* 한다. + +**이유 2: 라이브 운영의 빠른 튜닝 사이클** + +신규 게임 출시 초기 MMR 분포가 정규분포로 수렴하기까지 몇 주~몇 달 걸린다. 그 동안 지표 보며 분 단위로 룰을 튜닝해야 하는데, 배포 프로세스(PR → review → merge → deploy)는 이 사이클과 안 맞는다. + +**이유 3: 운영자 권한 모델** + +게임 운영팀이 PR 권한 + CI/CD 권한을 갖는 모델은 보안/책임 측면에서 부적합. + +### 하이브리드 (DB + Git 백업) + +DB가 primary, Git은 audit/backup 용도. 검토 가치 있으나 복잡도 증가 대비 이득 명확하지 않음. + +## Notes + +이 결정이 다른 결정들에 미치는 영향: + +- **ADR-006 (DB 분리)**: Config가 런타임 critical이 되므로 runtime DB와 격리 필요 +- **ADR-002 (Polling)**: Config 변경 반영 latency 1~10초가 운영적으로 충분 +- **ADR-005-ME-version-propagation**: Config 변경 시 in-flight ticket 처리 정책 명확화 필요 + +Unity Matchmaker, AWS FlexMatch, PlayFab Matchmaking 등 상용 매치메이커가 모두 런타임 설정 변경을 지원하는 것으로 보인다. 라이브 게임 운영의 표준 요구사항. diff --git a/docs/decisions/006-db-separation.md b/docs/decisions/006-db-separation.md new file mode 100644 index 0000000..8834c14 --- /dev/null +++ b/docs/decisions/006-db-separation.md @@ -0,0 +1,79 @@ +# ADR-006: Runtime DB와 Config DB 분리 + +## Status + +Accepted (2026-05-23) + +## Context + +매치메이커가 다루는 데이터는 크게 두 종류: + +- **Runtime 데이터**: tickets, matches, ticket_pool_membership (운영 중 빈번한 read/write) +- **Config 데이터**: 큐/풀/룰 설정 + 변경 이력 (read-heavy, 가끔 write, 라이브 튜닝) + +ADR-005에서 Config가 런타임 critical path에 들어왔다 (즉시 변경 반영 필요). 이 상태에서 한 DB에 둘지, 분리할지 결정 필요. + +## Decision + +**두 개의 Postgres 인스턴스로 분리한다.** + +- `postgres-runtime`: inbox(없으면 생략), tickets, matches, ticket lifecycle +- `postgres-config`: queues, pools, rules, config_history, attribute_schemas + +둘 다 Postgres이므로 운영 노하우는 공유된다. + +## Consequences + +### 장점 + +- **운영 격리** — Config DB 작업이 매칭 시스템에 영향 없음 + - 큰 트랜잭션, 마이그레이션, 운영자 실수가 운영 DB와 격리 +- **다른 백업/보존 정책 가능** — Config는 장기 보존 + audit, Runtime은 짧게 +- **다른 권한 모델** — Config DB는 운영자/관리자 접근, Runtime은 서비스 계정만 +- **다른 scaling 전략** — Config는 read replica 적극 활용 가능 +- **장애 격리** — Config DB 장애 시 cached config로 매칭 계속 동작 (ADR-002 stale cache 정책) + +### 단점 + +- **운영 부담 증가** — Postgres 인스턴스 2개 +- **Helm chart 복잡도 증가** — 의존성 명세가 길어짐 +- **트랜잭션 분리** — Config 변경과 Runtime 상태 변경을 한 트랜잭션에 묶을 수 없음 (필요 없는 경우가 대부분이므로 큰 문제 아님) + +## Alternatives Considered + +### 단일 Postgres, schema로 분리 + +```sql +create schema runtime; +create schema config; +``` + +- 장점: 운영 단순, Helm chart 단순, 트랜잭션 자유 +- 단점: + - Config 변경 작업 (큰 트랜잭션, 마이그레이션)이 운영 DB에 영향 + - 백업 정책이 일률적 (transient runtime도 같이 백업) + - Config DB 장애 = 매칭 시스템 장애 + +ADR-005에서 *Config가 런타임 critical이 되었기 때문에* 단일 DB는 부적합. 만약 Config가 GitOps 기반이었다면 단일 DB가 맞았을 것. + +### 완전 분리 (3개 DB) + +- runtime / config / inbox 각각 분리 +- Over-engineering. 600 RPS 규모에서 정당화 어려움. + +## Notes + +이 결정의 핵심: **둘 다 Postgres라는 점**. + +운영팀이 새로운 기술을 배우거나 새로운 운영 노하우를 쌓을 필요 없다. 백업, 모니터링, 튜닝 모두 같은 패턴. 그래서 "Postgres 2대"는 여전히 **minimalistic 원칙과 호환**된다. + +만약 Config Store가 etcd, S3, Git 등 다른 시스템이었다면 운영 부담이 진짜 늘었을 것. Postgres-Postgres 분리는 부담 적음. + +Sentry, Mattermost 같은 설치형 OSS도 비슷한 패턴: 운영 DB와 별개의 작은 DB(또는 schema)를 사용한다. + +향후 multi-region 운영이 필요해지면: + +- Runtime DB는 region-local primary +- Config DB는 global write + region read replica + +자연스럽게 확장 가능한 구조. diff --git a/docs/decisions/007-allocator-agnostic-pull-api.md b/docs/decisions/007-allocator-agnostic-pull-api.md new file mode 100644 index 0000000..832dabb --- /dev/null +++ b/docs/decisions/007-allocator-agnostic-pull-api.md @@ -0,0 +1,170 @@ +# ADR-007: Allocator-agnostic Pull-only API + +## Status + +Accepted (2026-05-23) + +## Context + +매치메이커가 생성한 매치는 결국 DGS(Dedicated Game Server)에 할당되어야 한다. DGS fleet 관리 방식은 게임사마다 천차만별: + +- Agones (K8s-native) +- AWS GameLift +- PlayFab Multiplayer Servers +- 자체 Kubernetes (StatefulSet/Deployment) +- 베어메탈 풀 + 자체 스케줄러 + +매치메이커가 이 다양한 시스템을 어떻게 통합할지가 OSS 도구의 도입 장벽을 결정한다. + +## Decision + +**DGS Allocator는 외부 시스템으로 두고, 매치메이커는 API Gateway를 통한 Pull-only API만 노출한다.** + +- 매치메이커는 외부 Allocator API를 호출하지 않는다 +- Allocator는 **API Gateway를 통해** 매치메이커를 polling한다 (Entity Store 직접 접근 불가, ADR-003) +- 매치메이커는 Allocator의 존재 자체를 모른다 (추상화 비용 zero) +- 참조 구현(adapter)을 examples로 제공 + +### API 명세 + +```protobuf +service MatchmakerBackend { + rpc ClaimMatches(ClaimMatchesRequest) returns (ClaimMatchesResponse); + rpc ReportAssignment(ReportAssignmentRequest) returns (ReportAssignmentResponse); + rpc ReleaseMatch(ReleaseMatchRequest) returns (ReleaseMatchResponse); +} + +message ClaimMatchesRequest { + string allocator_id = 1; // 어느 allocator인지 + repeated string queue_ids = 2; // 관심 있는 큐 (필터링) + int32 max_matches = 3; // batch 크기 + map labels = 4; // region, capability 등 +} + +message ClaimMatchesResponse { + repeated Match matches = 1; + string lease_token = 2; + google.protobuf.Timestamp lease_expires_at = 3; +} + +message ReportAssignmentRequest { + string match_id = 1; + oneof result { + AssignmentSuccess success = 2; // dgs_address, dgs_port 등 + AssignmentFailure failure = 3; // 실패 사유 + } +} +``` + +### Adapter 패턴 + +``` +matchmaker/ +├── services/ # 매치메이커 본체 +└── examples/ + ├── agones-adapter/ # 참조 구현 + ├── kubernetes-adapter/ + └── README.md # 사용자 정의 adapter 가이드 +``` + +Adapter는 매치메이커 외부에서: +1. `ClaimMatches` polling +2. 자기 Allocator API 호출 (Agones Allocation API 등) +3. `ReportAssignment`로 결과 통보 + +## Consequences + +### 장점 + +- **매치메이커 자체가 깨끗하게 유지** — Allocator 통합 코드 없음 +- **모든 Allocator 지원 가능** — Adapter만 작성하면 됨 +- **운영 환경 무관** — K8s, AWS, 베어메탈 등 어디든 OK +- **테스트 단순** — 매치메이커 테스트에 mock Allocator 불필요 (인터페이스만 검증) + +### 단점 + +- **사용자 부담 약간 증가** — Adapter 작성 필요 (단, 참조 구현 제공) +- **Pull 모델의 latency** — Allocator polling interval만큼 지연 (보통 1초) + +### Lease 패턴 + +ClaimMatches로 가져간 매치는 임시 locked (`state = 'assigning'`). lease 시간 내 `ReportAssignment`가 오지 않으면 reaper가 자동으로 `ready`로 복원. + +```sql +-- Reaper job (주기 실행) +update matches set state = 'ready', assigned_to_allocator = null +where state = 'assigning' and claimed_at < now() - interval '1 minute'; +``` + +### 매치 dequeue 쿼리 + +Allocator의 `ClaimMatches` gRPC 호출을 받은 **API Gateway가 Entity Store에 다음 쿼리를 실행**한다: + +```sql +update matches set + state = 'assigning', + assigned_to_allocator = $allocator_id, + claimed_at = now() +where id in ( + select id from matches + where state = 'ready' + and ($2::text[] is null or queue_id = any($2)) -- queue 필터 + order by created_at + limit $3 + for update skip locked +) +returning *; +``` + +SKIP LOCKED로 여러 Allocator가 동시에 polling해도 서로 다른 매치를 받음. Allocator는 Entity Store를 직접 알지 못한다. + +## Alternatives Considered + +### Push 모델 (매치메이커가 Allocator API 호출) + +- 장점: latency 낮음 +- 단점: + - Allocator별 SDK/API 통합 필요 (Agones, GameLift, PlayFab 각각) + - 매치메이커가 Allocator의 가용성에 의존 + - 의존성 폭발 (각 SDK 버전 관리) +- **OSS 도입 장벽이 매우 높아짐** → 부적합 + +### Allocator 내장 (Agones 전용) + +- 장점: 단순, K8s 친화적 +- 단점: + - 특정 환경에 lock-in + - 베어메탈, AWS 등 사용자 배제 +- **시장 축소** → 부적합 + +### Webhook 콜백 + +- 매치메이커가 매치 생성 시 등록된 webhook URL 호출 +- 장점: latency 낮음 +- 단점: + - Webhook 등록 메커니즘 필요 + - 신뢰성 보장 어려움 (재시도, 순서) + - polling이 더 단순 + +## Notes + +이 결정이 Open Match와 차별화되는 지점이다: + +- Open Match: Director를 외부 사용자 구현 (매칭 알고리즘 + DGS 통합 모두 사용자 부담) +- 본 설계: Director, ME, Evaluator 내장, **외부 통합은 Allocator 한 곳만** + +사용자 입장에서: +- 매칭 룰: CEL config로 정의 +- DGS 통합: 작은 adapter 작성 (참조 구현 활용) + +이게 인디 게임사도 도입 가능한 수준의 진입 장벽이다. + +### 다중 Allocator + region routing + +여러 Allocator가 동시 운영되는 경우 (region별 등) **매치에 region 라벨이 명확히 박혀있는 모델**을 권장: + +- ticket의 region attribute → 매치의 region 라벨 +- region별 Allocator는 자기 region 매치만 ClaimMatches +- region 간 분산은 Director의 분류 단계에서 처리 (region별 pool) + +이게 fairness 문제와 routing 복잡도를 모두 단순화한다. diff --git a/docs/design-discussion.md b/docs/design-discussion.md new file mode 100644 index 0000000..5edb4d2 --- /dev/null +++ b/docs/design-discussion.md @@ -0,0 +1,564 @@ +# Matchmaker 설계 토론 정리 + +> Postgres 기반 minimalistic 매치메이커 OSS 설계에 대한 토론 정리 + +--- + +## 1. 부하 산정과 RPS 추정 + +### 1.1 초기 가정으로 시작한 부하 계산 + +처음에는 다음 가정으로 부하를 추정함: + +- 동접 20만 명 +- 매치 생성 요청 + 폴링 (1초 1회) +- 게임 30분 진행 +- 4인 매치, 1티켓 = 1명 + +**잘못된 모델**: 클라이언트 1인당 1Hz polling 가정 → 200,000 QPS라는 비현실적 수치. + +### 1.2 폴링 모델 정정 + +폴링은 클라이언트별 상태 조회가 아니라 **컨슈머가 큐에서 매치 이벤트를 끌어가는 워커 패턴**임을 정정. + +``` +새 매치 생성률 = 200,000 / 1,800초 ≈ 111 ticket/s +완성 매치 = 111 / 4 ≈ 28 match/s +``` + +폴링 QPS는 동접 수가 아닌 *컨슈머 수 × 폴링 주기*로 결정됨. 실질 부하는 무시 가능. + +### 1.3 결론: 부하는 문제가 아니다 + +| 동접 | 매치 생성률 | +|---|---| +| 20만 | ~28 match/s | +| 100만 | ~139 match/s | + +이 수준의 부하는 단일 Go 프로세스 + 단일 Postgres로 충분히 처리 가능. **매치메이커 설계의 본질적 어려움은 throughput이 아닌 매칭 품질, 백프레셔, 운영성에 있음**. + +--- + +## 2. 시스템 요구사항 + +### 2.1 목표 명세 + +- 동접 100만 가능, **500~600 RPS** +- 백필 지원 +- 동적인 커스텀 어트리뷰트 (큐 설정 기반 payload 관리) +- expr language 기반 동적 매칭 룰 (cel-go 등) +- 관제 기능 +- gRPC 기반 인터페이스 +- Helm chart 설치형 배포 +- **Minimalistic 원칙**: Redis 등 외부 의존성 없이 Postgres만으로 달성 + +### 2.2 Postgres-only 가능성 검토 + +가능. 600 RPS는 Postgres가 압도적으로 여유 있게 처리하는 수준. 다만 구현 패턴이 중요: + +- **`SELECT ... FOR UPDATE SKIP LOCKED`**: 분산 컨슈머 워커 패턴 +- **JSONB + GIN/generated column**: 동적 attribute 저장 +- **LISTEN/NOTIFY**: 이벤트 통보 (best-effort, fallback polling 필요) +- **트랜잭션**: 매치 상태 전이의 일관성 보장 + +### 2.3 매치메이커의 진짜 설계 난점 + +부하가 아닌 다음 항목이 본질적 어려움: + +1. **매칭 품질** — MMR, 핑, 파티, 역할 구성 등 다차원 최적화 +2. **큐 체류 시간의 tail latency** — 평균보다 p99가 더 중요 +3. **백프레셔와 부분 장애** — 다운스트림(DGS) 장애 대응 +4. **파티 처리** — 솔로 vs 파티 우선순위 +5. **운영성** — A/B 테스트, 룰 튜닝, 옵저버빌리티 + +--- + +## 3. 백프레셔와 Circuit Breaker + +### 3.1 단순 차단 모델의 함정 + +"매치 큐 적체 → 티켓 차단"은 너무 단순. 적체 원인에 따라 대응이 달라야 함: + +- **Case A**: 매칭 알고리즘이 느림 → 티켓 차단은 매칭 자체를 멈춤 +- **Case B**: DGS 프로비저닝 막힘 → 티켓 차단이 정답 +- **Case C**: 폴링 컨슈머 죽음 → 컨슈머 살리면 됨 + +증상은 같아도 원인과 대응이 다름. + +### 3.2 매치메이킹 circuit breaker의 특수성 + +일반적인 HTTP Hystrix 패턴과 다름: + +- **실패율이 아닌 큐 깊이 + age가 신호**: `ApproximateAgeOfOldestMessage`, consumer lag 등 +- **Binary가 아닌 점진적 차단**: 수락률을 100% → 80% → 50%로 AIMD 방식 +- **거절 비용이 큼**: 사용자가 게임 자체를 못 하게 됨 → 거절보다 긴 대기 + 다른 모드 유도 선호 + +### 3.3 계층적 대응 패턴 + +| 적체 정도 | 대응 | +|---|---| +| 정상 | 통상 동작 | +| 약간 (lag > 30s) | DGS fleet 확장 트리거, 알람 | +| 심각 (lag > 2min) | 신규 티켓 수락률 점진 감소 (50%~10%) | +| 위험 (lag > 5min) | 신규 티켓 완전 차단, drain 모드 | +| 복구 | 점진적 ramp-up (slow ramp으로 oscillation 방지) | + +핵심: +- 신호는 **queue depth × age** +- 대응은 **계층적/점진적** +- 차단은 **mode/region별 isolated** +- 복구는 **slow ramp** + +--- + +## 4. 아키텍처 진화 + +### 4.1 OpenMatch 스타일 출발 + +초기 구상: + +- **API Gateway**: 모든 API 요청 처리 +- **MatchingEngine (다수 병렬)**: pool별 매치 후보 생성 +- **Evaluator**: 후보 중 품질 최고 매치 선별 + 중복 제거 + +### 4.2 다중 Pool 멤버십 모델 + +한 큐에 여러 pool이 있고, 한 ticket이 여러 pool에 동시 속할 수 있음 (Pool 범위 overlap). + +이 모델에서: +- ticket이 여러 pool에 등장 → 같은 ticket이 여러 MatchingEngine의 후보가 됨 → **중복 제거가 구조적으로 필요** → Evaluator 필수 + +### 4.3 책임 분리: API Gateway는 큐/풀 설정을 모른다 + +**핵심 통찰**: 부하나 성능이 아닌 **bounded context 분리** 관점. + +| Gateway가 알아야 함 | Gateway가 몰라도 됨 | +|---|---| +| RPC schema (gRPC proto) | Pool 정의 | +| Queue ID (라우팅용) | Pool 분류 룰 | +| 인증 정보 | Attribute schema | +| Ticket의 raw payload | CEL 룰 | + +이점: +- 큐/풀 설정 변경이 Gateway 재배포 불필요 +- Gateway는 진짜 stateless + dumb +- 멀티 테넌시 자연스러움 +- 외부 노출 면에서 비즈니스 로직 격리 (보안) +- 설정 검증의 단일 소유자 + +### 4.4 Pull 모델 vs Push 모델 + +**Pull 모델 (DB-centric)**: +- ME가 DB에서 SELECT (with SKIP LOCKED) +- Pool 멤버십을 DB에 영속화 (`ticket_pool_membership` 테이블) +- 운영자 친화적, SQL로 디버깅 + +**Push 모델 (Director-centric)**: +- Director가 batch pull → 메모리에서 분류 → ME로 push +- Pool 멤버십이 Director의 in-memory 상태 +- 설정 버전을 ticket과 함께 전파 가능 (일관성 우월) +- Director가 SPOF에 가까움 + +**최종 선택: Hybrid (push 모델 채택)**: +- Ticket lifecycle은 DB에 (관찰성) +- Pool 분류는 Director 메모리에 (유연성) +- Director가 dispatch에 config_version 명시 + +--- + +## 5. 최종 컴포넌트 구조 + +### 5.1 컴포넌트 목록 + +| 컴포넌트 | 역할 | 인스턴스 | +|---|---|---| +| **API Gateway** | gRPC API, 티켓 영속화, 큐/풀 무지 | 다수 (stateless) | +| **Director** | 큐 설정 캐싱, batch pull, pool 분류, ME로 dispatch | 1 active + standby | +| **MatchingEngine (ME)** | 자기 pool의 매치 후보 생성 | pool 수만큼 | +| **Evaluator** | 후보 aggregation, 중복 제거, 최종 매치 확정 | 1 active + standby | +| **Config Syncer** | Config Store polling, 컴포넌트에 config API 제공 | 1 active + standby | +| **Entity Store (Postgres)** | tickets, matches, ticket lifecycle | 단일 DB | +| **Config Store (Postgres)** | 큐/풀/룰 설정, 변경 이력 | 별도 DB | + +### 5.2 데이터 흐름 + +``` +[Client] → [Game Backend] → [API Gateway] + ↓ Insert Tickets + [Entity Store] + ↑ Polling (read tickets) + [Director] ← Sync Config ← [Config Syncer] ← Polling ← [Config Store] + ↓ Distribute Tickets (by pool, with config_version) + [Matching Engine × N] + ↓ Aggregate Match Candidates + [Evaluator] + ↓ Matches + [Entity Store] + ↑ Find Matches (polling, claim batch) + [External DGS Allocator] + ↓ + [DGS Fleet] +``` + +### 5.3 DB 분리 결정 + +**runtime DB와 config DB를 분리**: + +이유: +- Config가 *런타임 critical path*에 들어옴 (라이브 튜닝) +- 변경 빈도와 패턴이 다름 (운영 DB는 빈번, config는 분~시간 단위) +- Config DB 장애 시 cached config로 매칭 계속 가능 (장애 격리) +- 다른 백업/보존 정책 (config는 long retention) +- 다른 권한 모델 + +둘 다 Postgres이므로 운영 부담 큰 차이 없음. + +--- + +## 6. Config 관리 + +### 6.1 런타임 변경 요구사항 + +YAML/Git 기반은 부적합. 이유: + +1. **큐 설정과 게임 서버 파라미터의 바인딩** — 롤백 시 게임 서버도 롤백 필요, 무중단 롤백 어려움 +2. **라이브 운영 이슈 대응** — MMR 분포 튜닝 등 분 단위 사이클 필요 + +따라서 **DB 기반 런타임 변경 API**가 정답. + +### 6.2 Config Syncer 도입 + +- Config Store와 컴포넌트 사이의 캐싱 + API 계층 +- Config Store 장애 시 cached config로 매칭 계속 가능 (격리) +- 검증/변환 로직 단일화 +- 컴포넌트가 Config Store 스키마를 몰라도 됨 + +### 6.3 동기화 방식: Polling 채택 + +**판단 근거**: +- Director cycle이 1초이므로 그보다 빠른 실시간성 무의미 +- 실제 매치 대기 시간이 수십 초~수 분이므로 config 반영 1~2초 지연은 무시 가능 +- 운영자가 변경 후 효과 관찰까지 분 단위 소요됨 +- Polling이 단순함, 장애 복구 자명, 운영자 mental model 단순 + +**Stale cache 정책**: +- Syncer는 마지막 성공 config를 무기한 유지 +- Config Store 장애 중에도 매칭 계속 동작 +- 라이브 게임에서 매칭 중단보다 약간의 stale config가 훨씬 나음 + +### 6.4 ME의 lazy config fetch + +ME 동기화 전략: +- ME는 단일 config version만 cache +- Director가 보낸 ticket의 config_version이 cache와 다르면 → Syncer에서 해당 version fetch → cache 교체 +- 매칭은 항상 ticket에 명시된 version으로 진행 + +장점: +- Memory footprint 최소 +- Director가 권위 있는 version 발급자 (일관성 보장) +- Syncer 부담 낮음 (변경 시에만 fetch) +- 자동 동기화 (별도 broadcast 불필요) +- Pull-based versioning 패턴 (K8s resource version, Git commit hash와 동일 원리) + +### 6.5 Config version의 의미 + +큐별 version 권장: +- 큐 X가 변경되면 큐 X의 version++ +- 다른 큐는 영향 없음 +- ME가 큐별로 cache 가능 + +``` +Config Syncer API: + GET /config/queues/{queue_id}/versions/latest + GET /config/queues/{queue_id}/versions/{version} + +Ticket dispatch metadata: + { + ticket_id, pool_id, queue_id, + config_version: { queue_id: 'rank-4v4', version: 124 } + } +``` + +### 6.6 부분 업데이트 방지 + +Config 변경은 단일 트랜잭션으로: + +```sql +begin; + update queues set ... where queue_id = 'X'; + update pools set ... where queue_id = 'X' and pool_id = 'Y'; + insert into pools (...); + update config_meta set version = version + 1 where queue_id = 'X'; +commit; +``` + +Syncer는 version 기반 polling으로 atomic 변경 보장. + +--- + +## 7. 통신 모델 + +### 7.1 외부 통신: Polling 강제 + +매치메이커의 외부 인터페이스(`API Gateway` 노출)는 **polling 기반**: + +- gRPC streaming 대비 폴링의 장점: + - 표준 HTTP/2 unary RPC (일반 LB로 충분) + - L4/L7 로드밸런서 설정 단순 + - Keep-alive 튜닝 부담 없음 + - 클라이언트 재시도 시맨틱 자명 + - 디버깅 단순 (각 요청 독립) +- 설치형 OSS의 운영 부담을 최소화하는 결정 + +### 7.2 게임 서버 통보 모델 + +**중요**: 클라이언트는 매치메이커를 직접 호출하지 않음. + +``` +[Client] → [Game Backend] → [Matchmaker API Gateway] + ← [DGS Allocator (외부)] ← polling for matches + → [DGS Fleet] +``` + +- 게임 백엔드가 ticket 생성 요청 +- DGS Allocator가 매치메이커를 polling하여 매치 가져감 +- DGS Allocator가 fleet에 게임 서버 할당 +- 게임 서버 정보가 ticket에 박힘 → 게임 백엔드가 ticket polling으로 확인 → 클라이언트에 통보 + +### 7.3 부하 재정리 + +매치메이커 시스템 전체 부하 (100만 동접 기준): + +| 컴포넌트 | 부하 | +|---|---| +| API Gateway: CreateTicket | ~556 QPS | +| Director: Entity Store polling | 1 QPS (1초 cycle, batch) | +| Director → ME: dispatch | 1 cycle/s | +| ME → Evaluator: candidates | 1 cycle/s | +| Evaluator → Entity Store: match insert | ~139 매치/s | +| DGS Allocator: match polling | 1 QPS (batch dequeue ~139개) | +| Config Syncer: Config Store polling | ~0.2 QPS | + +**유일한 의미 있는 부하는 CreateTicket의 ~556 QPS**. 단일 Postgres가 압도적으로 여유 있게 처리. + +--- + +## 8. DGS Allocator 통합 + +### 8.1 Allocator-agnostic 설계 + +특정 Allocator(Agones, GameLift, PlayFab 등)에 바인딩하지 않음. + +### 8.2 Pull-only 인터페이스 + +매치메이커는 외부 Allocator API를 호출하지 않음. Allocator가 매치메이커를 polling. + +```protobuf +service MatchmakerBackend { + rpc ClaimMatches(ClaimMatchesRequest) returns (ClaimMatchesResponse); + rpc ReportAssignment(ReportAssignmentRequest) returns (ReportAssignmentResponse); + rpc ReleaseMatch(ReleaseMatchRequest) returns (ReleaseMatchResponse); +} + +message ClaimMatchesRequest { + string allocator_id = 1; + repeated string queue_ids = 2; + int32 max_matches = 3; + map labels = 4; // region, capability 등 +} + +message ClaimMatchesResponse { + repeated Match matches = 1; + string lease_token = 2; + google.protobuf.Timestamp lease_expires_at = 3; +} +``` + +### 8.3 Lease 패턴 + +ClaimMatches로 가져간 매치는 임시 locked. lease 시간 내 `ReportAssignment` 없으면 자동 복원 (`state = 'ready'`로 reaper가 되돌림). + +### 8.4 매치 dequeue 쿼리 + +```sql +update matches set + state = 'assigning', + assigned_to_allocator = $allocator_id, + claimed_at = now() +where id in ( + select id from matches + where state = 'ready' + order by created_at + limit 500 + for update skip locked +) +returning *; +``` + +### 8.5 Adapter 패턴 + +OSS 배포 시 참조 구현 제공: + +``` +matchmaker/ +├── helm/ +├── proto/ +├── server/ # 매치메이커 본체 +└── examples/ + ├── agones-adapter/ + ├── kubernetes-adapter/ + └── README.md +``` + +Adapter는 매치메이커 외부에서: +- `ClaimMatches` polling +- 자기 Allocator API 호출 +- `ReportAssignment`로 결과 통보 + +--- + +## 9. 데이터 모델 + +### 9.1 핵심 테이블 + +```sql +-- Entity Store +create table tickets ( + id uuid primary key, + queue_id text not null, + payload jsonb not null, + state text not null, -- created, in_director, matched, assigned, ... + created_at timestamptz default now(), + expires_at timestamptz, + matched_at timestamptz, + config_version int -- 분류된 시점의 version +); +create index on tickets (state, created_at); +create index on tickets (queue_id, state); + +create table matches ( + id uuid primary key, + queue_id text not null, + ticket_ids uuid[] not null, + quality_score float, + state text not null, -- ready, assigning, assigned, completed + config_version int, + created_at timestamptz default now(), + claimed_at timestamptz, + assigned_to_allocator text, + dgs_info jsonb +); +create index on matches (state, created_at); + +-- Config Store (별도 DB) +create table queues ( + queue_id text primary key, + config jsonb not null, + version int not null, + updated_at timestamptz default now() +); + +create table config_history ( + queue_id text not null, + version int not null, + config jsonb not null, + changed_at timestamptz default now(), + changed_by text, + primary key (queue_id, version) +); +``` + +### 9.2 Ticket State Machine + +``` +created (Gateway INSERT) + ↓ +in_director (Director batch pull, lease) + ↓ +matched (Evaluator 확정) + ↓ +assigned (Allocator가 DGS 할당 완료) + ↓ +in_game / completed / cancelled / expired +``` + +### 9.3 Match State Machine + +``` +forming (Director가 매칭 중) + ↓ +ready (Evaluator 확정, Allocator 할당 대기) + ↓ +assigning (Allocator가 claim, lease 중) + ↓ +assigned (DGS 할당 완료) + ↓ +active → completed / failed / backfilling +``` + +--- + +## 10. Open Match와의 차별점 + +| 측면 | Open Match | 본 설계 | +|---|---|---| +| 의존성 | Redis + K8s | Postgres + Helm | +| Director | 사용자 구현 | 내장 | +| MMF (매칭 함수) | 사용자 구현 (gRPC) | CEL 룰 (config) | +| Evaluator | 사용자 구현 | 내장 | +| 백필 | 약함 | 일급 시민 | +| 라이브 룰 튜닝 | 재배포 필요 | 런타임 변경 API | +| 진입 장벽 | 높음 (Director/MMF 직접 구현) | 낮음 (config만) | +| 트랜잭션 일관성 | Lua 등 우회 필요 | Postgres native | + +**포지셔닝**: Open Match가 못 채우는 시장 (라이브 운영 친화적, 백필 강함, 진입장벽 낮음) 공략. + +--- + +## 11. 미해결 / 다음 단계 + +### 11.1 추가 설계 결정거리 + +1. **Director batch pull 전략** — limit N, lease timeout 튜닝 +2. **ME dispatch 단위** — pool 단위 통째 vs 더 작은 분할 +3. **Evaluator cycle 동기화** — Director cycle과의 맞물림 +4. **Ticket TTL과 expiry** — 너무 오래 기다린 ticket 처리 +5. **Region/label 기반 routing** — 다중 Allocator 운영 시 +6. **백필 데이터 흐름** — 활성 게임에 ticket 추가 시 통보 경로 + +### 11.2 다이어그램 보강 필요 + +- Ticket lifecycle 상태 전이 +- Match lifecycle 상태 전이 +- 백필 데이터 흐름 +- Director의 active-passive HA +- Config version 흐름 +- Reaper / TTL cleanup 메커니즘 + +### 11.3 다음 산출물 후보 + +- 상태 머신 다이어그램 (Ticket, Match, Director lease) +- 시퀀스 다이어그램 (happy path + 장애 시나리오) +- gRPC proto 정의 초안 +- Deployment 다이어그램 (인스턴스 다중성, leader election) + +### 11.4 백필 통보 경로 (미정) + +``` +일반 매치: Director → ME → Evaluator → matches table → Allocator polling → DGS +백필 매치: Director → ME → Evaluator → backfill table → ??? → 기존 DGS +``` + +백필은 활성 게임 인스턴스로 직접 통보되어야 하므로 별도 데이터 흐름 설계 필요. + +--- + +## 12. 핵심 원칙 요약 + +1. **Minimalistic 의존성**: Postgres 2대 (runtime + config) + 컴포넌트들. Redis, Kafka, NATS 없음. +2. **Polling 기반 통신**: 운영 견고성과 단순성 우선. Streaming은 필요한 곳만. +3. **Bounded context 분리**: API Gateway는 큐/풀 무지, Director가 큐/풀 권위자, ME는 매칭 로직, Evaluator는 일관성 수문장. +4. **Pull + lease 패턴**: SKIP LOCKED로 분산 컨슈머, lease로 장애 복구. +5. **Pull-based config versioning**: Director가 version 발급, 컴포넌트가 lazy fetch. +6. **외부 Allocator는 추상화**: Pull-only API로 매치메이커는 Allocator 모름. +7. **라이브 운영 first-class**: Config 런타임 변경, stale cache fallback, 점진적 backpressure. \ No newline at end of file diff --git a/docs/design/matchmaker_0523rev3.png b/docs/design/matchmaker_0523rev3.png new file mode 100644 index 0000000..2439487 Binary files /dev/null and b/docs/design/matchmaker_0523rev3.png differ diff --git a/docs/design/overview.md b/docs/design/overview.md new file mode 100644 index 0000000..327a95f --- /dev/null +++ b/docs/design/overview.md @@ -0,0 +1,284 @@ +# Matchmaker — System Overview + +> 시스템 전체를 한 페이지로 요약. 상세 의사결정은 `docs/decisions/`, 컴포넌트별 디테일은 `docs/design/components/`에 분리. + +## 1. 목표 + +Postgres 기반 minimalistic 매치메이커. Open Match의 대안 포지셔닝. + +**기능 목표**: +- 동접 100만 명, 600 RPS 처리 +- 백필 지원 (일급 시민) +- 큐별 동적 커스텀 어트리뷰트 +- CEL 기반 동적 매칭 룰 (런타임 변경 가능) +- 관제 기능 +- gRPC 인터페이스 +- Helm chart 설치형 배포 + +**비기능 목표**: +- 의존성 최소화 (Postgres만, 외부 큐/캐시 없음) +- 라이브 운영 친화적 (재배포 없이 룰 튜닝) +- Allocator-agnostic (Agones, GameLift, PlayFab 등 모두 통합 가능) + +## 2. 시스템 경계 + +``` +┌─────────────────────────── 외부 ────────────────────────────┐ +│ │ +│ Game Client → Game Backend ──┐ │ +│ ├──gRPC──→ ┌──────────────┐ │ +│ External DGS Allocator ──────┘ │ API Gateway │ │ +│ └──────┬───────┘ │ +│ │ │ +└───────────────────────────────────────────────────┼──────────┘ + │ +┌─────────────────────────── 내부 ──────────────────┼──────────┐ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ Entity Store │ │ +│ │ (Postgres) │ │ +│ └──┬───────────┘ │ +│ │ polling │ +│ ┌──────────▼─────────┐ │ +│ │ Director │◄─────┼─┐ +│ └──────────┬─────────┘ │ │ +│ │ dispatch │ │ +│ ┌──────────▼─────────┐ │ │ +│ │ MatchingEngine × N │◄─────┼─┤ +│ └──────────┬─────────┘ │ │ +│ │ candidates │ │ +│ ┌──────────▼─────────┐ │ │ +│ │ Evaluator │◄─────┼─┤ +│ └──────────┬─────────┘ │ │ +│ │ matches │ │ +│ ┌──────────▼─────────┐ │ │ +│ │ Entity Store │ │ │ +│ └────────────────────┘ │ │ +│ │ │ +│ ┌────────────────────┐ │ │ +│ │ Config Syncer │──────sync config─────┼─┘ +│ └──────────┬─────────┘ │ +│ │ polling │ +│ ┌──────────▼─────────┐ │ +│ │ Config Store │ │ +│ │ (Postgres) │ │ +│ └────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +**핵심 원칙**: 외부 시스템은 오직 API Gateway만 통해 매치메이커와 통신. 내부 컴포넌트는 외부에 노출되지 않음 (ADR-003). + +## 3. 컴포넌트 + +| 컴포넌트 | 역할 | 인스턴스 | 상태 | +|---|---|---|---| +| **API Gateway** | 외부 진입점, 인증, ticket CRUD, 매치 dequeue | 다수 (stateless, 수평 확장) | stateless | +| **Director** | 큐 설정 캐싱, batch pull, pool 분류, ME로 dispatch | 1 active + standby | stateful (lease) | +| **MatchingEngine** | pool당 1개, 매치 후보 생성 | pool 수만큼 | stateless (config cache만) | +| **Evaluator** | 후보 aggregation, 충돌 해결, 최종 매치 확정 | 1 active + standby | stateless | +| **Config Syncer** | Config Store polling 캐싱, 컴포넌트에 config API 제공 | 1 active + standby | cache | +| **Entity Store** | tickets, matches 영속화 | Postgres 1대 | stateful | +| **Config Store** | 큐/풀/룰 설정 + 변경 이력 | Postgres 1대 (runtime DB와 분리) | stateful | + +## 4. 외부 API + +API Gateway가 노출하는 gRPC service: + +```protobuf +service MatchmakerFrontend { + // Game Backend가 호출 + rpc CreateTicket(CreateTicketRequest) returns (CreateTicketResponse); + rpc GetTicket(GetTicketRequest) returns (GetTicketResponse); // polling + rpc CancelTicket(CancelTicketRequest) returns (CancelTicketResponse); +} + +service MatchmakerBackend { + // External DGS Allocator가 호출 + rpc ClaimMatches(ClaimMatchesRequest) returns (ClaimMatchesResponse); + rpc ReportAssignment(ReportAssignmentRequest) returns (ReportAssignmentResponse); + rpc ReleaseMatch(ReleaseMatchRequest) returns (ReleaseMatchResponse); +} + +service MatchmakerAdmin { + // 운영자가 호출 (라이브 룰 튜닝) + rpc UpsertQueueConfig(UpsertQueueConfigRequest) returns (UpsertQueueConfigResponse); + rpc GetQueueConfig(GetQueueConfigRequest) returns (GetQueueConfigResponse); + rpc ListConfigHistory(ListConfigHistoryRequest) returns (ListConfigHistoryResponse); + rpc RollbackConfig(RollbackConfigRequest) returns (RollbackConfigResponse); +} +``` + +모두 gRPC unary RPC (streaming 없음, ADR-002). + +## 5. 데이터 모델 (핵심만) + +### Ticket lifecycle + +``` +created (Gateway INSERT) + ↓ +in_director (Director batch pull, lease) + ↓ +matched (Evaluator 확정) + ↓ +assigned (Allocator가 DGS 할당 완료) + ↓ +in_game → completed / cancelled / expired +``` + +### Match lifecycle + +``` +forming (Director가 매칭 중, 메모리 only) + ↓ +ready (Evaluator 확정, Allocator 할당 대기) + ↓ +assigning (Allocator가 claim, lease 중) + ↓ +assigned (DGS 정보 박힘, ticket에 전파) + ↓ +active → completed / failed / backfilling +``` + +### 핵심 테이블 요약 + +```sql +-- Entity Store (runtime DB) +tickets -- id, queue_id, payload (jsonb), state, config_version, timestamps +matches -- id, queue_id, ticket_ids[], state, quality_score, + -- config_version, dgs_info (jsonb), assignment 메타 + +-- Config Store (별도 DB) +queues -- queue_id, config (jsonb), version, updated_at +config_history -- queue_id, version, config, changed_at, changed_by +``` + +상세는 `docs/design/data-model.md` 참조 (작성 예정). + +## 6. 핵심 흐름 + +### 6.1 매칭 happy path + +1. Game Backend → Gateway: `CreateTicket(queue_id, payload)` +2. Gateway → Entity Store: ticket INSERT (state=`created`) +3. Game Backend → Gateway: `GetTicket(ticket_id)` polling (1초) +4. Director (1초 cycle): + - Entity Store에서 `created` ticket batch pull (state=`in_director`) + - 메모리에서 큐별 config로 pool 분류 + - `{ticket, pool_id, config_version}`을 해당 ME로 push +5. MatchingEngine: + - 받은 ticket batch를 자기 pool 후보로 누적 + - config_version이 cache와 다르면 Syncer에서 fetch + - CEL 룰로 매치 후보 생성 + - Evaluator로 push +6. Evaluator: + - 모든 ME의 후보 수집 (cycle 단위) + - 중복 ticket 충돌 해결 (quality score 기반) + - 트랜잭션: ticket state=`matched`, match INSERT (state=`ready`) +7. External DGS Allocator → Gateway: `ClaimMatches(...)` polling +8. Gateway → Entity Store: `SELECT ... FOR UPDATE SKIP LOCKED` (state=`assigning`, lease) +9. Allocator: 자기 fleet에서 DGS 인스턴스 시작 +10. Allocator → Gateway: `ReportAssignment(match_id, dgs_address)` +11. Gateway → Entity Store: match state=`assigned`, ticket에 dgs_info 박음 +12. Game Backend의 `GetTicket` polling이 dgs_info 받음 → 클라이언트에 통보 + +### 6.2 Config 변경 흐름 + +1. 운영자 → Gateway → Config Store: `UpsertQueueConfig(...)` 트랜잭션 (version++) +2. Config Syncer가 polling으로 변경 감지 → cache 갱신 +3. Director가 Syncer에서 새 config fetch (다음 cycle) +4. Director가 새 config_version으로 ticket 분류 +5. ME가 ticket의 config_version이 cache와 다르면 Syncer에서 lazy fetch + +상세는 `docs/design/flows/` 참조 (작성 예정). + +## 7. 부하 특성 + +100만 동접 기준 추정: + +| 컴포넌트 | QPS | 비고 | +|---|---|---| +| Gateway CreateTicket | ~556 | 티켓 발급률 | +| Gateway GetTicket | ~수천 | Game Backend의 polling | +| Gateway ClaimMatches | 1~수십 | Allocator polling, batch | +| Director → Entity Store | 1 | 1초 cycle | +| Evaluator → matches insert | ~139 | 매치 생성률 | +| Config Syncer → Config Store | ~0.2 | 5초 polling | + +**유일한 의미 있는 부하는 Gateway의 CreateTicket + GetTicket**. 단일 Postgres가 압도적으로 여유 있게 처리. 부하 측면에서 Postgres scaling 우려 없음. + +## 8. 운영 특성 + +### 가용성 + +- **Gateway**: stateless, 다수 인스턴스 + LB +- **Director, Evaluator, Syncer**: active-passive + Postgres advisory lock 기반 leader election +- **ME**: pool당 1개 active + standby 또는 multi-active (큐 분할) +- **Entity Store/Config Store**: 사용자 운영 책임 (read replica는 선택) + +### Stale config 정책 + +Config Store 일시 장애 시 Syncer는 마지막 성공 config를 무기한 유지. 매칭은 계속 동작. "최근 변경한 룰이 늦게 반영됨"이 "매칭 시스템 다운"보다 낫다는 원칙. + +### Lease + Reaper 패턴 + +- Director가 ticket batch pull 시 `in_director` 상태 + leased_at +- Allocator가 ClaimMatches 시 `assigning` 상태 + claimed_at +- 별도 reaper job이 stale lease를 원래 상태로 복원 (Director/Allocator 다운 대응) + +### 백프레셔 + +매치 큐 (`state='ready'`) depth와 oldest age를 모니터링: +- 약간 적체 → DGS fleet autoscale 트리거 +- 심각 적체 → 신규 ticket 수락률 점진 감소 +- 위험 적체 → ticket 수락 완전 차단 + drain 모드 +- 복구 시 slow ramp (oscillation 방지) + +## 9. 디렉토리 구조 (계획) + +``` +matchmaker/ +├── CLAUDE.md +├── proto/ # gRPC 정의 +├── services/ # 매치메이커 본체 +│ ├── gateway/ +│ ├── director/ +│ ├── engine/ +│ ├── evaluator/ +│ └── syncer/ +├── helm/ # Helm chart +├── examples/ +│ ├── agones-adapter/ +│ └── kubernetes-adapter/ +└── docs/ + ├── design/ + │ ├── overview.md # 이 문서 + │ ├── data-model.md # 스키마, 인덱스, 상태 머신 상세 + │ ├── api.md # gRPC proto + 시맨틱 + │ ├── components/ # 컴포넌트별 디테일 + │ ├── flows/ # 시퀀스 다이어그램, 장애 시나리오 + │ └── operations/ # 배포, 모니터링, 튜닝 + ├── decisions/ # ADR + └── design-discussion.md # 초기 토론 전문 +``` + +## 10. 미해결 항목 + +- **백필 데이터 흐름**: 활성 게임 인스턴스로의 통보 경로 (일반 매치는 Allocator polling, 백필은 기존 DGS 직접 통보 필요) +- **Region/label 기반 routing**: 다중 Allocator 운영 시 매치 분배 정책 +- **Evaluator cycle 동기화**: Director cycle과의 정확한 맞물림 (현재 1초 cycle 가정) +- **충돌 해결 알고리즘**: greedy vs maximum weight matching +- **Ticket TTL**: 너무 오래 기다린 ticket 처리 정책 + +## 11. 참조 + +- ADR-001: Postgres-only 의존성 +- ADR-002: Polling 기반 통신 +- ADR-003: API Gateway는 유일한 외부 진입점이자 큐/풀 설정 무지 +- ADR-004: Director Push 모델 +- ADR-005: Config 런타임 변경 (DB 기반) +- ADR-006: Runtime DB와 Config DB 분리 +- ADR-007: Allocator-agnostic Pull-only API + +Open Match와의 차별점은 ADR 전반에서 다룸. 요약하면: 의존성 최소(Postgres만), Director/Evaluator 내장, 라이브 룰 튜닝, 백필 일급, 진입 장벽 낮음. \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 085250f..0000000 --- a/docs/index.html +++ /dev/null @@ -1,264 +0,0 @@ -Matchmaker API

Matchmaker API

API Endpoint

Matchmaker API (WIP)

-

Ticket API

Creates or Deletes matchmaking request.

-

Resource

POST /tickets
Requestsexample 1
Headers
Content-Type: application/json
Body
{
-  "ticket_id": "abc123",
-  "player_ids": [
-    "player1",
-    "player2"
-  ],
-  "time": "2025-07-23T15:04:05Z"
-}
Responses200
Headers
Content-Type: application/json
Body
{
-  "message": "Ticket created successfully",
-  "ticket_id": "abc123"
-}

POST/tickets

Creates a matchmaking request by sending a ticket.

-

Resource

DELETE /tickets/ticket_id
Responses200
Headers
Content-Type: application/json
Body
{
-  "message": "Ticket cancelled successfully"
-}

DELETE/tickets/{ticket_id}

Cancels a matchmaking request.

-
URI Parameters
HideShow
ticket_id
string (required) 

ID of the ticket

-

Match API

Finds and acknowledges match candidates

-

Resource

GET /matches/candidates
Responses200
Headers
Content-Type: application/json
Body
[
-  {
-    "id": "match-001",
-    "tickets": [
-      {
-        "id": "ticket-101",
-        "player_ids": [
-          "player-1",
-          "player-2"
-        ],
-        "timestamp": "2025-07-26T08:00:00Z"
-      },
-      {
-        "id": "ticket-102",
-        "player_ids": [
-          "player-3",
-          "player-4"
-        ],
-        "timestamp": "2025-07-26T08:05:00Z"
-      }
-    ]
-  }
-]

GET/matches/candidates


Generated by aglio on 28 Jul 2025

\ No newline at end of file diff --git a/docs/matchmaking.apib b/docs/matchmaking.apib deleted file mode 100644 index 6b17f93..0000000 --- a/docs/matchmaking.apib +++ /dev/null @@ -1,64 +0,0 @@ -FORMAT: 1A - -# Matchmaker API - -Matchmaker API (WIP) - -## Group Ticket API -Creates or Deletes matchmaking request. - -### POST /tickets -Creates a matchmaking request by sending a ticket. - -+ Request (application/json) - - { - "ticket_id": "abc123", - "player_ids": ["player1", "player2"], - "time": "2025-07-23T15:04:05Z" - } - -+ Response 200 (application/json) - - { - "message": "Ticket created successfully", - "ticket_id": "abc123" - } - -### DELETE /tickets/{ticket_id} -Cancels a matchmaking request. - -+ Parameters - + ticket_id (string) - ID of the ticket - -+ Response 200 (application/json) - - { - "message": "Ticket cancelled successfully" - } - -## Group Match API - -Finds and acknowledges match candidates - -### GET /matches/candidates - -+ Response 200 (application/json) - - [ - { - "id": "match-001", - "tickets": [ - { - "id": "ticket-101", - "player_ids": ["player-1", "player-2"], - "timestamp": "2025-07-26T08:00:00Z" - }, - { - "id": "ticket-102", - "player_ids": ["player-3", "player-4"], - "timestamp": "2025-07-26T08:05:00Z" - } - ] - } - ] \ No newline at end of file diff --git a/docs/sample_requests/api.md b/docs/sample_requests/api.md deleted file mode 100644 index 7e0e8e6..0000000 --- a/docs/sample_requests/api.md +++ /dev/null @@ -1,12 +0,0 @@ -# API - -### create ticket -```bash -curl -X POST http://localhost:8080/tickets \ - -H "Content-Type: application/json" \ - -d '{ - "ticket_id": "abc123", - "player_ids": ["player1", "player2"], - "time": "2025-07-23T15:04:05Z" - }' -``` \ No newline at end of file diff --git a/go.mod b/go.mod deleted file mode 100644 index 34805fc..0000000 --- a/go.mod +++ /dev/null @@ -1,29 +0,0 @@ -module github.com/chaewonkong/matchmaker - -go 1.25 - -require ( - github.com/go-playground/validator/v10 v10.27.0 - github.com/google/uuid v1.6.0 - github.com/labstack/echo/v4 v4.13.4 - github.com/stretchr/testify v1.10.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 9f0038d..0000000 --- a/go.sum +++ /dev/null @@ -1,45 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mise.toml b/mise.toml deleted file mode 100644 index ae35574..0000000 --- a/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -go = "1.25.4" \ No newline at end of file diff --git a/schema/config.go b/schema/config.go deleted file mode 100644 index d1f5092..0000000 --- a/schema/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package schema - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -type MatchingStrategy string - -const ( - // player vs Environment - PvE MatchingStrategy = "PvE" - - // Nop - Nop MatchingStrategy = "Nop" - - // DualTeam red team vs blue team - DualTeam MatchingStrategy = "DualTeam" -) - -// QueueConfig represents the configuration for the matchmaking queue. -type QueueConfig struct { - Name string `json:"name" yaml:"name"` - ID string `json:"id" yaml:"id"` - Version string `json:"version" yaml:"version"` - TeamLayout TeamLayout `json:"team_layout" yaml:"team_layout"` - Strategy MatchingStrategy `json:"matching_strategy" yaml:"matching_strategy"` -} - -// TeamLayout represents the layout of teams in the matchmaking system. -type TeamLayout struct { - NumberOfTeams int `json:"number_of_teams" yaml:"number_of_teams"` - TeamCapacity int `json:"team_capacity" yaml:"team_capacity"` -} - -// NewQueueConfig constructor -func NewQueueConfig() *QueueConfig { - return &QueueConfig{} -} - -// UnmarshalFromYAML reads the QueueConfig from a YAML file at the specified path. -func (c *QueueConfig) UnmarshalFromYAML(path string) error { - f, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to open queue config file: %w", err) - } - - err = yaml.NewDecoder(f).Decode(c) - if err != nil { - return fmt.Errorf("failed to decode queue config file: %w", err) - } - - return nil -} diff --git a/schema/entity.go b/schema/entity.go deleted file mode 100644 index f607cf4..0000000 --- a/schema/entity.go +++ /dev/null @@ -1,33 +0,0 @@ -package schema - -import "time" - -// Ticket represents a matchmaking ticket. -type Ticket struct { - ID string `json:"id" validate:"required"` - PlayerIDs []string `json:"player_ids" validate:"required"` - Timestamp time.Time `json:"timestamp" validate:"required"` -} - -// Player represents a player. -type Player struct { - ID string `json:"id"` -} - -// Match represents a match -type Match struct { - ID string `json:"id"` - Teams []Team `json:"teams"` -} - -// Team represents a team -type Team struct { - // offset, index, id, number, order, - Index int `json:"index"` - Tickets []Ticket -} - -// MatchResult represents the result of a match. -type MatchResult struct { - MatchID string `json:"match_id"` -} diff --git a/schema/request.go b/schema/request.go deleted file mode 100644 index bc35a38..0000000 --- a/schema/request.go +++ /dev/null @@ -1,25 +0,0 @@ -package schema - -import "time" - -// TicketRequest create ticket request -type TicketRequest struct { - ID string `json:"ticket_id" validate:"required"` - PlayerIDs []string `json:"player_ids" validate:"required"` - Time string `json:"time" validate:"required"` // ISO 8601 format -} - -// ToTicket converts TicketRequest to Ticket -func (t *TicketRequest) ToTicket() (tkt Ticket, err error) { - // parse ISO 8601 time format - timestamp, err := time.Parse(time.RFC3339, t.Time) - if err != nil { - return - } - - tkt.ID = t.ID - tkt.PlayerIDs = t.PlayerIDs - tkt.Timestamp = timestamp - - return -} diff --git a/services/apiserver/list/list.go b/services/apiserver/list/list.go deleted file mode 100644 index 47ea2ca..0000000 --- a/services/apiserver/list/list.go +++ /dev/null @@ -1,31 +0,0 @@ -package list - -import container "container/list" - -type List[T any] struct { - list *container.List -} - -func New[T any]() List[T] { - return List[T]{ - list: container.New(), - } -} - -func (l List[T]) Len() int { - return l.list.Len() -} - -func (l List[T]) Push(v T) { - l.list.PushBack(v) -} - -func (l List[T]) Pop() (T, bool) { - v := l.list.Back() - if v != nil { - val := l.list.Remove(v) - return val.(T), true - } - var zero T - return zero, false -} diff --git a/services/apiserver/list/list_test.go b/services/apiserver/list/list_test.go deleted file mode 100644 index b48edac..0000000 --- a/services/apiserver/list/list_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package list_test - -import ( - "testing" - - "github.com/chaewonkong/matchmaker/services/apiserver/list" - "github.com/stretchr/testify/assert" -) - -type mockStruct struct { - name string -} - -func TestList(t *testing.T) { - s := mockStruct{ - name: "Leon", - } - - l := list.New[mockStruct]() - - assert.Equal(t, 0, l.Len()) - l.Push(s) - assert.Equal(t, 1, l.Len()) - - v, ok := l.Pop() - assert.True(t, ok) - assert.Equal(t, 0, l.Len()) - assert.Equal(t, s, v) - - _, ok = l.Pop() - assert.False(t, ok) -} diff --git a/services/apiserver/server.go b/services/apiserver/server.go deleted file mode 100644 index eb416a1..0000000 --- a/services/apiserver/server.go +++ /dev/null @@ -1,100 +0,0 @@ -package apiserver - -import ( - "net/http" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase" - "github.com/labstack/echo/v4" -) - -// Handler api handler -type Handler struct { - ticketService *usecase.TicketService - matchService *usecase.MatchService -} - -// NewHandler creates a new API handler -func NewHandler(ts *usecase.TicketService, ms *usecase.MatchService) *Handler { - return &Handler{ticketService: ts, matchService: ms} -} - -// CreateTicket handles the creation of a matchmaking ticket -func (h *Handler) CreateTicket(c echo.Context) error { - // Implementation for creating a ticket - t := schema.TicketRequest{} - err := c.Bind(&t) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) - } - - // Validate the ticket - err = c.Validate(&t) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - - tkt, err := t.ToTicket() - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid ticket time data"}) - } - - h.ticketService.Add(tkt) - - return c.JSON(http.StatusOK, map[string]string{"message": "Ticket created successfully", "ticket_id": t.ID}) -} - -// DeleteTicketByID handles the cancellation of a matchmaking ticket by ID -func (h *Handler) DeleteTicketByID(c echo.Context) error { - ticketID := c.Param("ticket_id") - - err := h.ticketService.RemoveByID(ticketID) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) - } - - return c.JSON(http.StatusOK, map[string]string{"message": "Ticket cancelled successfully"}) -} - -// FindAllMatchCandidates retrieves all current match candidates -func (h *Handler) FindAllMatchCandidates(c echo.Context) error { - matches, err := h.matchService.FindAllMatchCandidates() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "find match failed"}) - } - - return c.JSON(http.StatusOK, matches) -} - -// CreateOrUpdateMatchAck handles the creation or update of player acknowledgement for a match -func (h *Handler) CreateOrUpdateMatchAck(c echo.Context) error { - // Implementation for creating or updating match acknowledgement - return nil -} - -// CreateMatchResult handles the submission of game results (win/loss) -func (h *Handler) CreateMatchResult(c echo.Context) error { - // Implementation for creating match results - r := &schema.MatchResult{} - err := c.Bind(r) - if err != nil { - return c.JSON(400, map[string]string{"error": "Invalid request body"}) - } - - // Validate the match result - err = c.Validate(&r) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - - return nil -} - -// RegisterRoutes registers the API routes with the provided Echo instance -func RegisterRoutes(e *echo.Echo, h *Handler) { - e.POST("/tickets", h.CreateTicket) - e.DELETE("/tickets/:ticket_id", h.DeleteTicketByID) - e.GET("/matches/candidates", h.FindAllMatchCandidates) - e.PUT("/matches/:match_id/ack", h.CreateOrUpdateMatchAck) - e.POST("/match-results", h.CreateMatchResult) -} diff --git a/services/apiserver/usecase/match_service.go b/services/apiserver/usecase/match_service.go deleted file mode 100644 index 0ea06be..0000000 --- a/services/apiserver/usecase/match_service.go +++ /dev/null @@ -1,43 +0,0 @@ -package usecase - -import ( - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase/strategy" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase/strategy/dualteam" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase/strategy/pve" - "github.com/chaewonkong/matchmaker/services/queue" -) - -// MatchService match service -type MatchService struct { - strategy strategy.Strategy -} - -// NewMatchService constructor -func NewMatchService(cfg *schema.QueueConfig, q *queue.MatchingQueue) (*MatchService, error) { - switch cfg.Strategy { - case schema.PvE: - return &MatchService{ - pve.PvEStrategy{ - Queue: q, - QueueConfig: cfg, - }, - }, nil - case schema.DualTeam: - return &MatchService{ - dualteam.DualteamStrategy{ - Queue: q, - QueueConfig: cfg, - }, - }, nil - default: // nop - return &MatchService{ - strategy.NopStrategy{}, - }, nil - } -} - -// FindAllMatchCandidates searches all possible match candidates -func (ms *MatchService) FindAllMatchCandidates() ([]schema.Match, error) { - return ms.strategy.FindMatchCandidates() -} diff --git a/services/apiserver/usecase/strategy/dualteam/dual_team_strategy.go b/services/apiserver/usecase/strategy/dualteam/dual_team_strategy.go deleted file mode 100644 index e164e1a..0000000 --- a/services/apiserver/usecase/strategy/dualteam/dual_team_strategy.go +++ /dev/null @@ -1,70 +0,0 @@ -package dualteam - -import ( - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/apiserver/list" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase/strategy" - "github.com/chaewonkong/matchmaker/services/queue" -) - -var _ strategy.Strategy = (*DualteamStrategy)(nil) - -// DualteamStrategy dual team layout strategy -type DualteamStrategy struct { - Queue *queue.MatchingQueue - QueueConfig *schema.QueueConfig -} - -// FindMatchCandidates finds match candidates in dual team layout -func (d DualteamStrategy) FindMatchCandidates() ([]schema.Match, error) { - numTeams := d.QueueConfig.TeamLayout.NumberOfTeams - teamCap := d.QueueConfig.TeamLayout.TeamCapacity - - // generate teams first - teams := list.New[schema.Team]() - - for d.Queue.Len() > 0 { - team := schema.Team{} - - for len(team.Tickets) < teamCap { - tkt, ok := d.Queue.Dequeue() - if !ok { - break // discard - } - - team.Tickets = append(team.Tickets, tkt) - } - teams.Push(team) - } - - // TODO: DI - return CandidateBuilder{}.Build(teams, numTeams) -} - -// CandidateBuilder composes match candidates from generated teams -type CandidateBuilder struct{} - -// Build composes match candidates from generated teams -func (c CandidateBuilder) Build(teams list.List[schema.Team], numTeams int) ([]schema.Match, error) { - candidates := []schema.Match{} - for teams.Len() > 0 { - m := schema.Match{} - - // TODO: shuffle teams, or make each team in match candidate fair and even. - // FIXME: this code discards team when match team layout is not satisfied. - for i := range numTeams { - if team, ok := teams.Pop(); ok { - team.Index = i - m.Teams = append(m.Teams, team) - } - } - - // if m has enough teams, append m to candidates - if len(m.Teams) == numTeams { - candidates = append(candidates, m) - } - // if len(m.Teams) < numTeams, discard m - } - - return candidates, nil -} diff --git a/services/apiserver/usecase/strategy/pve/pve_strategy.go b/services/apiserver/usecase/strategy/pve/pve_strategy.go deleted file mode 100644 index 9894fbc..0000000 --- a/services/apiserver/usecase/strategy/pve/pve_strategy.go +++ /dev/null @@ -1,67 +0,0 @@ -package pve - -import ( - "fmt" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/queue" - "github.com/google/uuid" -) - -// PvEStrategy PvE strategy -type PvEStrategy struct { - Queue *queue.MatchingQueue - QueueConfig *schema.QueueConfig -} - -// FindMatchCandidates finds match candidates according to PvE strategy -func (pe PvEStrategy) FindMatchCandidates() ([]schema.Match, error) { - matchCandidates := []schema.Match{} - cap := pe.QueueConfig.TeamLayout.TeamCapacity - - if cap < 1 { - return nil, fmt.Errorf("error team capacity must be greater than 0") - } - - for pe.Queue.Len() > 0 { - - candidate := []schema.Ticket{} - rejected := []schema.Ticket{} - slots := cap - for { - if slots == 0 { - // full - break - } - - tkt, ok := pe.Queue.Dequeue() - if !ok { - break - } - - n := len(tkt.PlayerIDs) - if n > slots { // too many players - rejected = append(rejected, tkt) - continue - } - candidate = append(candidate, tkt) - slots -= n - } - - // add candidate - if slots == 0 { - matchID := uuid.NewString() - teams := []schema.Team{ - {Index: 0, Tickets: candidate}, - } - matchCandidates = append(matchCandidates, schema.Match{ID: matchID, Teams: teams}) - } - - // add rejected tickets to queue again - for _, tkt := range rejected { - pe.Queue.Enqueue(tkt) - } - } - - return matchCandidates, nil -} diff --git a/services/apiserver/usecase/strategy/pve/pve_strategy_test.go b/services/apiserver/usecase/strategy/pve/pve_strategy_test.go deleted file mode 100644 index 8991454..0000000 --- a/services/apiserver/usecase/strategy/pve/pve_strategy_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package pve_test - -import ( - "testing" - "time" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/apiserver/usecase/strategy/pve" - "github.com/chaewonkong/matchmaker/services/queue" - "github.com/stretchr/testify/assert" -) - -func TestPvEStrategyFindMatchCandidates(t *testing.T) { - t.Run("Finds match candidates successfully", func(t *testing.T) { - // given - queue := queue.New() - cfg := &schema.QueueConfig{ - ID: "queue1", - Name: "player vs environment", - TeamLayout: schema.TeamLayout{ - NumberOfTeams: 1, - TeamCapacity: 4, - }, - Strategy: schema.PvE, - } - s := &pve.PvEStrategy{ - Queue: queue, - QueueConfig: cfg, - } - now := time.Now() - - t1 := schema.Ticket{ - ID: "1", - PlayerIDs: []string{"p1", "p2"}, - Timestamp: now, - } - t2 := schema.Ticket{ - ID: "2", - PlayerIDs: []string{"p3", "p4", "p5"}, - Timestamp: now.Add(1 * time.Second), - } - t3 := schema.Ticket{ - ID: "3", - PlayerIDs: []string{"p6"}, - Timestamp: now.Add(2 * time.Second), - } - t4 := schema.Ticket{ - ID: "4", - PlayerIDs: []string{"p7"}, - Timestamp: now.Add(3 * time.Second), - } - t5 := schema.Ticket{ - ID: "5", - PlayerIDs: []string{"p8"}, - Timestamp: now.Add(4 * time.Second), - } - - // when - queue.Enqueue(t1) - queue.Enqueue(t2) - queue.Enqueue(t3) - queue.Enqueue(t4) - queue.Enqueue(t5) - - assert.Equal(t, 5, queue.Len()) - - // then - results, err := s.FindMatchCandidates() - assert.NoError(t, err) - - for _, match := range results { - cnt := 0 - - for _, team := range match.Teams { - for _, tkt := range team.Tickets { - cnt += len(tkt.PlayerIDs) - } - } - assert.Equal(t, 4, cnt) - } - - }) -} diff --git a/services/apiserver/usecase/strategy/strategy.go b/services/apiserver/usecase/strategy/strategy.go deleted file mode 100644 index 90e6829..0000000 --- a/services/apiserver/usecase/strategy/strategy.go +++ /dev/null @@ -1,17 +0,0 @@ -package strategy - -import "github.com/chaewonkong/matchmaker/schema" - -// Strategy match candidate finder algorithm -type Strategy interface { - // FindMatchCandidates finds match candidates - FindMatchCandidates() ([]schema.Match, error) -} - -// NopStrategy nop -type NopStrategy struct{} - -// FindMatchCandidates nop -func (NopStrategy) FindMatchCandidates() ([]schema.Match, error) { - return nil, nil -} diff --git a/services/apiserver/usecase/ticket_service.go b/services/apiserver/usecase/ticket_service.go deleted file mode 100644 index d4a3d16..0000000 --- a/services/apiserver/usecase/ticket_service.go +++ /dev/null @@ -1,35 +0,0 @@ -package usecase - -import ( - "fmt" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/queue" -) - -// TicketService is responsible for creating and managing matchmaking tickets. -type TicketService struct { - queue *queue.MatchingQueue -} - -// NewTicketService creates a new TicketCreator instance. -func NewTicketService(q *queue.MatchingQueue) *TicketService { - return &TicketService{ - queue: q, - } -} - -// Add creates a new matchmaking adds it to the queue. -func (t *TicketService) Add(ticket schema.Ticket) { - t.queue.Enqueue(ticket) -} - -// RemoveByID removes a matchmaking ticket from the queue by its ID. -func (t *TicketService) RemoveByID(ticketID string) error { - _, ok := t.queue.RemoveTicketByID(ticketID) - if !ok { - return fmt.Errorf("ticket with ID %s not found", ticketID) - } - - return nil -} diff --git a/services/apiserver/validator.go b/services/apiserver/validator.go deleted file mode 100644 index 77e989b..0000000 --- a/services/apiserver/validator.go +++ /dev/null @@ -1,26 +0,0 @@ -package apiserver - -import ( - "net/http" - - "github.com/go-playground/validator/v10" - "github.com/labstack/echo/v4" -) - -// CustomValidator implements the echo.Validator interface -type CustomValidator struct { - validator *validator.Validate -} - -// NewCustomValidator constructor -func NewCustomValidator() *CustomValidator { - return &CustomValidator{validator: validator.New()} -} - -// Validate validates the request body against the struct tags -func (cv *CustomValidator) Validate(i interface{}) error { - if err := cv.validator.Struct(i); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - return nil -} diff --git a/services/queue/interface.go b/services/queue/interface.go deleted file mode 100644 index f13569a..0000000 --- a/services/queue/interface.go +++ /dev/null @@ -1,16 +0,0 @@ -package queue - -import ( - "context" - - "github.com/chaewonkong/matchmaker/schema" -) - -// Queue where tickets wait to be matched -type Queue interface { - // Add add requested ticket to the queue - Add(ctx context.Context, ticket schema.Ticket) error - - // Fetch fetches top n number of tickets from queue. - Fetch(ctx, n int) ([]schema.Ticket, error) -} diff --git a/services/queue/matching_queue.go b/services/queue/matching_queue.go deleted file mode 100644 index cfae279..0000000 --- a/services/queue/matching_queue.go +++ /dev/null @@ -1,63 +0,0 @@ -package queue - -import ( - "container/heap" - - "github.com/chaewonkong/matchmaker/schema" -) - -// MatchingQueue is a priority queue for matchmaking tickets. -type MatchingQueue struct { - queue queue - index map[string]*ticketEntry -} - -// New creates a new MatchingQueue instance. -func New() *MatchingQueue { - return &MatchingQueue{ - queue: queue{}, - index: make(map[string]*ticketEntry), - } -} - -// Len returns the number of tickets in the queue. -func (q *MatchingQueue) Len() int { - return q.queue.Len() -} - -// Enqueue adds a ticket to the queue. -func (q *MatchingQueue) Enqueue(ticket schema.Ticket) { - if _, exists := q.index[ticket.ID]; exists { - return // Ticket already exists in the queue - } - entry := &ticketEntry{ - Ticket: ticket, - } - heap.Push(&q.queue, entry) - q.index[ticket.ID] = entry -} - -// Dequeue removes and returns the oldest ticket from the queue. -func (q *MatchingQueue) Dequeue() (schema.Ticket, bool) { - if q.Len() == 0 { - return schema.Ticket{}, false // Return an empty ticket if the queue is empty - } - - entry, ok := heap.Pop(&q.queue).(*ticketEntry) - delete(q.index, entry.Ticket.ID) - return entry.Ticket, ok -} - -// RemoveTicketByID removes a ticket from the queue by its ID. -func (q *MatchingQueue) RemoveTicketByID(ticketID string) (schema.Ticket, bool) { - entry, exists := q.index[ticketID] - if !exists { - return schema.Ticket{}, false // Ticket not found - } - - v := heap.Remove(&q.queue, entry.index) - tkt := v.(*ticketEntry).Ticket - delete(q.index, tkt.ID) - - return tkt, true -} diff --git a/services/queue/matching_queue_test.go b/services/queue/matching_queue_test.go deleted file mode 100644 index f4ad51a..0000000 --- a/services/queue/matching_queue_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package queue_test - -import ( - "testing" - "time" - - "github.com/chaewonkong/matchmaker/schema" - "github.com/chaewonkong/matchmaker/services/queue" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMatchingQueue(t *testing.T) { - t.Run("Len 0", func(t *testing.T) { - q := queue.New() - assert.NotPanics(t, func() { - assert.Equal(t, 0, q.Len(), "Expected queue length to be 0") - }) - }) - - t.Run("Enqueue", func(t *testing.T) { - q := queue.New() - assert.NotPanics(t, func() { - q.Enqueue(schema.Ticket{}) - assert.Equal(t, 1, q.Len(), "Expected queue length to be 1 after enqueue") - }) - }) - - t.Run("Dequeue 1 item", func(t *testing.T) { - q := queue.New() - ticketID := "ticket1" - assert.NotPanics(t, func() { - q.Enqueue(schema.Ticket{ID: ticketID}) - assert.Equal(t, 1, q.Len(), "Expected queue length to be 1 before dequeue") - ticket, ok := q.Dequeue() - assert.True(t, ok, "Expected dequeue to succeed") - assert.Equal(t, ticketID, ticket.ID, "Expected dequeued ticket ID to match") - assert.Equal(t, 0, q.Len(), "Expected queue length to be 0 after dequeue") - }) - }) - - t.Run("enqueue, dequeue order check", func(t *testing.T) { - q := queue.New() - now := time.Now() - tickets := []schema.Ticket{ - {ID: "1", Timestamp: now.Add(3 * time.Second)}, - {ID: "2", Timestamp: now.Add(2 * time.Second)}, - {ID: "3", Timestamp: now.Add(1 * time.Second)}, - } - - for _, tkt := range tickets { - q.Enqueue(tkt) - } - - assert.Equal(t, 3, q.Len(), "Expected queue length to be 3 after enqueueing 3 tickets") - - for i := 2; i >= 0; i-- { - ticket, ok := q.Dequeue() - assert.True(t, ok, "Expected dequeue to succeed") - assert.Equal(t, tickets[i].ID, ticket.ID, "Expected dequeued ticket ID to match") - } - }) - - t.Run("RemoveTicketByID", func(t *testing.T) { - q := queue.New() - now := time.Now() - tickets := []schema.Ticket{ - {ID: "1", Timestamp: now.Add(3 * time.Second)}, - {ID: "2", Timestamp: now.Add(2 * time.Second)}, - {ID: "3", Timestamp: now.Add(1 * time.Second)}, - } - - for _, tkt := range tickets { - q.Enqueue(tkt) - } - - assert.Equal(t, 3, q.Len(), "Expected queue length to be 3 after enqueueing 3 tickets") - - require.NotPanics(t, func() { - tkt, ok := q.RemoveTicketByID("2") - assert.True(t, ok, "Expected ticket with ID '2' to be removed successfully") - assert.Equal(t, "2", tkt.ID, "Expected removed ticket ID to match '2'") - - assert.Equal(t, 2, q.Len(), "Expected queue length to be 2 after removing one ticket") - - }) - }) -} diff --git a/services/queue/queue.go b/services/queue/queue.go deleted file mode 100644 index 8a7cdf4..0000000 --- a/services/queue/queue.go +++ /dev/null @@ -1,53 +0,0 @@ -package queue - -import ( - "container/heap" - - "github.com/chaewonkong/matchmaker/schema" -) - -type ticketEntry struct { - schema.Ticket - index int -} - -type queue []*ticketEntry - -// Less implements heap.Interface. -func (q queue) Less(i int, j int) bool { - return q[i].Timestamp.Before(q[j].Timestamp) -} - -// Pop implements heap.Interface. -func (q *queue) Pop() any { - old := *q - n := len(old) - ticket := old[n-1] - *q = old[0 : n-1] - - return ticket -} - -// Push implements heap.Interface. -func (q *queue) Push(x any) { - entry, ok := x.(*ticketEntry) - if !ok { - return - } - *q = append(*q, entry) -} - -// Swap implements heap.Interface. -func (q *queue) Swap(i int, j int) { - qs := *q - qs[i], qs[j] = qs[j], qs[i] - qs[i].index = i - qs[j].index = j -} - -// Len implements heap.Interface. -func (q queue) Len() int { - return len(q) -} - -var _ heap.Interface = (*queue)(nil)