Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!queue-runtime/build/libs/queue-runtime.jar
29 changes: 29 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Build

on:
pull_request:

concurrency:
group: build-${{ github.head_ref }}
cancel-in-progress: true

jobs:
build:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout sources
uses: actions/checkout@v7
with:
fetch-depth: '0'

- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 25

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

- name: Build with Gradle
run: chmod +x ./gradlew && ./gradlew build
117 changes: 117 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: Release

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: write
packages: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout sources
uses: actions/checkout@v7
with:
fetch-depth: '0'

- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 25

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

- name: Read the gradle version
id: version
run: |
chmod +x ./gradlew
VERSION=$(./gradlew -q --console=plain :queue-api:properties | awk '/^version:/ { print $2 }')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"

- name: Check for an existing release
id: existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "${{ steps.version.outputs.tag }}" > /dev/null 2>&1; then
echo "Release ${{ steps.version.outputs.tag }} already exists, skipping"
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Publish the api
if: steps.existing.outputs.exists == 'false'
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }}
run: ./gradlew :queue-api:publishAllPublicationsToPublicRepository

- name: Build the runtime jar
if: steps.existing.outputs.exists == 'false'
run: ./gradlew :queue-runtime:shadowJar

- name: Set up Docker Buildx
if: steps.existing.outputs.exists == 'false'
uses: docker/setup-buildx-action@v4

- name: Log in to GitHub Container Registry
if: steps.existing.outputs.exists == 'false'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
if: steps.existing.outputs.exists == 'false'
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ steps.version.outputs.version }}
type=sha,format=short

- name: Build and push the runtime image
if: steps.existing.outputs.exists == 'false'
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Create the release
if: steps.existing.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PRERELEASE=""
if [[ "${{ steps.version.outputs.version }}" == *-* ]]; then
PRERELEASE="--prerelease"
fi

gh release create "${{ steps.version.outputs.tag }}" \
--title "${{ steps.version.outputs.tag }}" \
--generate-notes \
$PRERELEASE
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM eclipse-temurin:25-jre

ARG JAR=queue-runtime/build/libs/queue-runtime.jar

WORKDIR /app

RUN useradd --system --uid 1000 queue \
&& mkdir -p types .secrets logs \
&& chown -R queue:queue /app

COPY --chown=queue:queue ${JAR} queue-runtime.jar

USER queue

ENTRYPOINT ["java","-jar", "queue-runtime.jar"]
124 changes: 80 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,92 @@
# Queue
# Queue v2

A microservice for queuing players into minigames.
A microservice that queue players into minigames and moves them to a game server.

## Architecture
## Concepts

### Queue Lifecycle
| Concept | What it is |
|----------------|----------------------------------------------------------------------------|
| **Ticket** | A player or a whole party that wants to play. |
| **Match** | A set of tickets that will play together on one server. |
| **Assignment** | The server a match was given. |
| **Queue type** | The configuration: which server group, how many players, how long to wait. |

Splitting the intent (ticket) from the result (match) is what makes the rest work. A ticket
can wait in several queue types at once, and matches can form independently of who asked
for what.

## How a match comes together

```mermaid
flowchart TD
NEP[NOT_ENOUGH_PLAYERS] -->|min players reached| WC[WAITING_COUNTDOWN]
WC -.->|players drop below min| NEP
WC -->|countdown expired or full| SS[SEARCHING_SERVER]
SS -->|server available| SR[SERVER_READY]
SS -->|no server free| WFS[WAITING_FOR_SERVER]
WFS -->|server becomes available| SR
SR --> CD[COUNTDOWN]
CD --> TP[TELEPORTING]
TP --> FIN[FINISHED]
A["CreateTicket"] --> B["Ticket: SEARCHING"]
B --> C{"Matchmaker"}
C -->|not enough players yet| B
C -->|full, or minimum reached<br/>and the oldest ticket waited long enough| D["Match: ALLOCATING"]
D -->|no server within 60s| F["Match: FAILED"]
F -->|tickets go back| B
D -->|server moved to INGAME| E["Match: COUNTDOWN<br/>Ticket: ASSIGNED"]
E -->|countdown over| G["Match: TRANSFERRING"]
G --> H["Match: COMPLETED<br/>players are on the game server"]
```

| Status | Description |
|----------------------|---------------------------------------------------------------|
| `NOT_ENOUGH_PLAYERS` | Waiting for the minimum player count |
| `WAITING_COUNTDOWN` | Minimum reached, counting down while waiting for more players |
| `SEARCHING_SERVER` | Reserving an available game server |
| `WAITING_FOR_SERVER` | No server available yet, waiting for one |
| `SERVER_READY` | Server reserved, starting the game countdown |
| `COUNTDOWN` | Final countdown before teleport |
| `TELEPORTING` | Transferring players to the game server |
| `FINISHED` | Cleanup: free server, delete queue |

### Queue Type Configuration

Queue types are defined as YAML files in the types directory:

```yml
name: bedwars
group: bedwars
max-capacity: 6
min-capacity: 12
waiting-countdown-seconds: 30
countdown-seconds: 10
1. **Searching**: the ticket sits in the pool of every queue type it asked for.
2. **Matchmaking**: a match is formed as soon as the queue type is full
3. **Allocating**: a free server of the group is moved to `INGAME`, which i.s what keeps the next match from taking it too.
4. **Countdown**: the ticket learns its server and when it will be moved, so a client can render the countdown itself instead of polling.
5. **Transferring**: every player is connected, then the match is done and its tickets are removed.

## Multi-Queue

A ticket can search in several queue types at the same time and joins whichever match fills
up first:

```kotlin
api.ticket().create {
party(members)
queues("battle", "skywars")
}
```

| Field | Description |
|-----------------------------|----------------------------------------------------------------|
| `name` | Unique identifier for this queue type |
| `group` | SimpleCloud server group to use for game servers |
| `min-capacity` | Minimum players required to start the waiting countdown |
| `max-capacity` | Maximum players per queue (starts immediately when full) |
| `waiting-countdown-seconds` | Seconds to wait for more players after minimum is reached |
| `countdown-seconds` | Seconds to count down before teleporting after server is ready |
## Modules

| Module | What is in it |
|-----------------|----------------------------------------------------------------------------|
| `queue-runtime` | The service: ticket store, matchmaker, match reconciler, server allocator. |
| `queue-api` | Java and Kotlin client, talks gRPC and listens to the NATS events. |
| `queue-shared` | Common shared files for the runtime and API. |
| `proto` | The protobuf definitions, published to the Buf Schema Registry. |

## Configuration

Queue types are YAML files in the types directory, one per queue:

```yaml
# types/battle.yml
name: battle
group: battle
min-players: 8
max-players: 16
waiting-duration-seconds: 30
countdown-duration-seconds: 10
```

Everything else comes from environment variables or a `queue.properties`

| Variable | Default |
|-------------------------|--------------------------------------|
| `GRPC_PORT` | `4564` |
| `NATS_URL` | `nats://localhost:4222` |
| `TYPE_PATH` | `types` |
| `AUTH_KEY_PATH` | `.secrets/auth.key` |
| `CONTROLLER_URL` | `https://controller.simplecloud.app` |
| `CONTROLLER_NATS_URL` | `wss://nats.simplecloud.app:443` |

## TODO
- [ x ] **Multi Queue**: Queue players in multiplie queues

- [x] **Multi Queue**: Let a player search in several queue types at once
- [ ] **Ticket TTL**: Drop tickets whose players went offline without leaving the queue
- [ ] **Metrics**: Time to match, fill rate, allocation latency, failed matches
- [ ] **Estimated wait**: Show players how long they will probably wait
- [ ] **Drain mode**: Finish the running matches before shutting down
- [ ] **Queue Rating**: Rate queues by how alive they are
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {

allprojects {
group = "net.mythicisland.queue"
version = "1.0.0"
version = "2.0.0-beta.1"

repositories {
mavenCentral()
Expand Down
7 changes: 1 addition & 6 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ slf4j = "2.0.18"

clikt = "5.1.0"
jnats = "2.26.0"
adventure = "5.2.0"
configurate = "4.2.0"
cloud-api = "0.1.0-platform.45"
queue-proto = "1.5.0.4.20260710134554.c3cd3d34e56f"
queue-proto = "1.5.0.4.20260803235217.273a6fa06034"
moonrise = "1.2.1"

protobuf = "4.35.1"
Expand All @@ -38,9 +37,6 @@ cloud-api = { module = "app.simplecloud.api:api", version.ref = "cloud-api" }
queue-proto = { module = "build.buf.gen:mythicisland_queue_grpc_kotlin", version.ref = "queue-proto" }
moonrise-common = { module = "net.mythicisland.moonrise:moonrise-common", version.ref = "moonrise" }

adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" }
adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }

protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }

grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" }
Expand All @@ -50,7 +46,6 @@ grpc-netty-shaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc"

[bundles]
logging = ["log4j-core", "log4j-api", "log4j-slf4j-impl", "slf4j-api"]
adventure = ["adventure-api", "adventure-text-minimessage"]
configurate = ["configurate-yaml", "configurate-extra-kotlin"]
grpc = ["grpc-stub", "grpc-netty-shaded", "grpc-kotlin-stub", "grpc-protobuf", "protobuf-kotlin"]

Expand Down
2 changes: 1 addition & 1 deletion proto/buf.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: v2
modules:
- path: mythicisland/queue/v1
- path: mythicisland/queue/v2
name: buf.build/mythicisland/queue
lint:
use:
Expand Down
Loading