Compare commits

..

24 Commits

Author SHA1 Message Date
Dmitry Popov
9a8dc4dbe5 Merge branch 'main' into tests-fixes 2025-11-22 12:29:22 +01:00
Dmitry Popov
7eb6d093cf fix(core): invalidate map characters every 1 hour for any missing/revoked permissions 2025-11-22 12:25:24 +01:00
CI
a23e544a9f chore: [skip ci] 2025-11-22 09:42:11 +00:00
CI
845ea7a576 chore: release version v1.85.3 2025-11-22 09:42:11 +00:00
Dmitry Popov
ae8fbf30e4 fix(core): fixed connection time status issues. fixed character alliance update issues 2025-11-22 10:41:35 +01:00
CI
3de385c902 chore: [skip ci] 2025-11-20 10:57:05 +00:00
CI
5f3d4dba37 chore: release version v1.85.2 2025-11-20 10:57:05 +00:00
Dmitry Popov
8acc7ddc25 fix(core): increased API pool limits 2025-11-20 11:56:31 +01:00
CI
ed6d25f3ea chore: [skip ci] 2025-11-20 10:35:09 +00:00
CI
ab07d1321d chore: release version v1.85.1 2025-11-20 10:35:09 +00:00
Dmitry Popov
a81e61bd70 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-20 11:31:39 +01:00
Dmitry Popov
d2d33619c2 fix(core): increased API pool limits 2025-11-20 11:31:36 +01:00
CI
fa464110c6 chore: [skip ci] 2025-11-19 23:13:02 +00:00
CI
a5fa60e699 chore: release version v1.85.0 2025-11-19 23:13:02 +00:00
Dmitry Popov
6db994852f feat(core): added support for new ship types 2025-11-20 00:12:30 +01:00
CI
0a68676957 chore: [skip ci] 2025-11-19 21:06:28 +00:00
CI
9b82dd8f43 chore: release version v1.84.37 2025-11-19 21:06:28 +00:00
Dmitry Popov
aac2c33fd2 fix(auth): fixed character auth issues 2025-11-19 22:05:49 +01:00
CI
1665b65619 chore: [skip ci] 2025-11-19 10:33:10 +00:00
CI
e1a946bb1d chore: release version v1.84.36 2025-11-19 10:33:10 +00:00
Dmitry Popov
543ec7f071 fix: fixed duplicated map slugs 2025-11-19 11:32:35 +01:00
CI
bf40d2cb8d chore: [skip ci] 2025-11-19 09:44:24 +00:00
Dmitry Popov
5e0965ead4 fix(tests): updated tests 2025-11-17 12:52:11 +01:00
Dmitry Popov
4c39c6fb39 fix(tests): updated tests 2025-11-17 00:09:10 +01:00
47 changed files with 2793 additions and 882 deletions

View File

@@ -1,9 +1,9 @@
name: Build Docker Image
name: Build Develop
on:
push:
tags:
- '**'
branches:
- develop
env:
MIX_ENV: prod
@@ -18,12 +18,85 @@ permissions:
contents: write
jobs:
build:
name: 🛠 Build
runs-on: ubuntu-22.04
if: ${{ github.ref == 'refs/heads/develop' && github.event_name == 'push' }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
matrix:
otp: ["27"]
elixir: ["1.17"]
node-version: ["18.x"]
outputs:
commit_hash: ${{ steps.set-commit-develop.outputs.commit_hash }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Setup Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}
# nix build would also work here because `todos` is the default package
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ssh-key: "${{ secrets.COMMIT_KEY }}"
fetch-depth: 0
- name: 😅 Cache deps
id: cache-deps
uses: actions/cache@v4
env:
cache-name: cache-elixir-deps
with:
path: |
deps
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
- name: 😅 Cache compiled build
id: cache-build
uses: actions/cache@v4
env:
cache-name: cache-compiled-build
with:
path: |
_build
key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles( '**/lib/**/*.{ex,eex}', '**/config/*.exs', '**/mix.exs' ) }}
restore-keys: |
${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-
${{ runner.os }}-build-
# Step: Download project dependencies. If unchanged, uses
# the cached version.
- name: 🌐 Install dependencies
run: mix deps.get --only "prod"
# Step: Compile the project treating any warnings as errors.
# Customize this step if a different behavior is desired.
- name: 🛠 Compiles without warnings
if: steps.cache-build.outputs.cache-hit != 'true'
run: mix compile
- name: Set commit hash for develop
id: set-commit-develop
run: |
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
docker:
name: 🛠 Build Docker Images
needs: build
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
@@ -37,6 +110,7 @@ jobs:
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare
run: |
@@ -46,25 +120,9 @@ jobs:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ needs.build.outputs.commit_hash }}
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -113,24 +171,6 @@ jobs:
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
@@ -161,9 +201,8 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
- name: Create manifest list and push
working-directory: /tmp/digests
@@ -176,12 +215,20 @@ jobs:
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
name: 🏷 Notify about develop release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL_DEV }}
content: |
📣 New develop release available 🚀
**Commit**: `${{ github.sha }}`
**Status**: Development/Testing Release
Docker image: `wandererltd/community-edition:develop`
⚠️ This is an unstable development release for testing purposes.

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- develop
env:
MIX_ENV: prod
@@ -22,7 +21,7 @@ jobs:
build:
name: 🛠 Build
runs-on: ubuntu-22.04
if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' }}
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
permissions:
checks: write
contents: write
@@ -37,7 +36,7 @@ jobs:
elixir: ["1.17"]
node-version: ["18.x"]
outputs:
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash || steps.set-commit-develop.outputs.commit_hash }}
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash }}
steps:
- name: Prepare
run: |
@@ -91,7 +90,6 @@ jobs:
- name: Generate Changelog & Update Tag Version
id: generate-changelog
if: github.ref == 'refs/heads/main'
run: |
git config --global user.name 'CI'
git config --global user.email 'ci@users.noreply.github.com'
@@ -102,15 +100,16 @@ jobs:
- name: Set commit hash for develop
id: set-commit-develop
if: github.ref == 'refs/heads/develop'
run: |
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
docker:
name: 🛠 Build Docker Images
if: github.ref == 'refs/heads/develop'
needs: build
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
@@ -137,6 +136,17 @@ jobs:
ref: ${{ needs.build.outputs.commit_hash }}
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -185,6 +195,24 @@ jobs:
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
@@ -215,8 +243,9 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
@@ -259,3 +288,14 @@ jobs:
## How to Promote?
In order to promote this to prod, edit the draft and press **"Publish release"**.
draft: true
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

View File

@@ -1,187 +0,0 @@
name: Build Docker ARM Image
on:
push:
tags:
- '**'
env:
MIX_ENV: prod
GH_TOKEN: ${{ github.token }}
REGISTRY_IMAGE: wandererltd/community-edition-arm
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
docker:
name: 🛠 Build Docker Images
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
fail-fast: false
matrix:
platform:
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
MIX_ENV=prod
BUILD_METADATA=${{ steps.meta.outputs.json }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer **ARM** release available 🎉
**Version**: :${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
- docker
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

View File

@@ -2,6 +2,60 @@
<!-- changelog -->
## [v1.85.3](https://github.com/wanderer-industries/wanderer/compare/v1.85.2...v1.85.3) (2025-11-22)
### Bug Fixes:
* core: fixed connection time status issues. fixed character alliance update issues
## [v1.85.2](https://github.com/wanderer-industries/wanderer/compare/v1.85.1...v1.85.2) (2025-11-20)
### Bug Fixes:
* core: increased API pool limits
## [v1.85.1](https://github.com/wanderer-industries/wanderer/compare/v1.85.0...v1.85.1) (2025-11-20)
### Bug Fixes:
* core: increased API pool limits
## [v1.85.0](https://github.com/wanderer-industries/wanderer/compare/v1.84.37...v1.85.0) (2025-11-19)
### Features:
* core: added support for new ship types
## [v1.84.37](https://github.com/wanderer-industries/wanderer/compare/v1.84.36...v1.84.37) (2025-11-19)
### Bug Fixes:
* auth: fixed character auth issues
## [v1.84.36](https://github.com/wanderer-industries/wanderer/compare/v1.84.35...v1.84.36) (2025-11-19)
### Bug Fixes:
* fixed duplicated map slugs
## [v1.84.35](https://github.com/wanderer-industries/wanderer/compare/v1.84.34...v1.84.35) (2025-11-19)

View File

@@ -177,7 +177,34 @@ config :wanderer_app,
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price
}
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations
# With 30+ TrackerPools × ~100 concurrent tasks, need large pool
finch_esi_character_pool_size:
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_SIZE", "200") |> String.to_integer(),
finch_esi_character_pool_count:
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_COUNT", "4") |> String.to_integer(),
# ESI General pool - standard capacity for general ESI operations
finch_esi_general_pool_size:
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_SIZE", "50") |> String.to_integer(),
finch_esi_general_pool_count:
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_COUNT", "4") |> String.to_integer(),
# Webhooks pool - isolated from ESI rate limits
finch_webhooks_pool_size:
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_SIZE", "25") |> String.to_integer(),
finch_webhooks_pool_count:
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_COUNT", "2") |> String.to_integer(),
# Default pool - everything else (email, license manager, etc.)
finch_default_pool_size:
System.get_env("WANDERER_FINCH_DEFAULT_POOL_SIZE", "25") |> String.to_integer(),
finch_default_pool_count:
System.get_env("WANDERER_FINCH_DEFAULT_POOL_COUNT", "2") |> String.to_integer(),
# Character tracker concurrency settings
# Location updates need high concurrency for <2s response with 3000+ characters
location_concurrency:
System.get_env("WANDERER_LOCATION_CONCURRENCY", "#{System.schedulers_online() * 12}")
|> String.to_integer()
config :ueberauth, Ueberauth,
providers: [

View File

@@ -16,15 +16,48 @@ defmodule WandererApp.Application do
WandererApp.Vault,
WandererApp.Repo,
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
# Multiple Finch pools for different services to prevent connection pool exhaustion
# ESI Character Tracking pool - high capacity for bulk character operations
{
Finch,
name: WandererApp.Finch.ESI.CharacterTracking,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_esi_character_pool_size, 100),
count: Application.get_env(:wanderer_app, :finch_esi_character_pool_count, 4)
]
}
},
# ESI General pool - standard capacity for general ESI operations
{
Finch,
name: WandererApp.Finch.ESI.General,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_esi_general_pool_size, 50),
count: Application.get_env(:wanderer_app, :finch_esi_general_pool_count, 4)
]
}
},
# Webhooks pool - isolated from ESI rate limits
{
Finch,
name: WandererApp.Finch.Webhooks,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_webhooks_pool_size, 25),
count: Application.get_env(:wanderer_app, :finch_webhooks_pool_count, 2)
]
}
},
# Default pool - everything else (email, license manager, etc.)
{
Finch,
name: WandererApp.Finch,
pools: %{
default: [
# number of connections per pool
size: 50,
# number of pools (so total 50 connections)
count: 4
size: Application.get_env(:wanderer_app, :finch_default_pool_size, 25),
count: Application.get_env(:wanderer_app, :finch_default_pool_count, 2)
]
}
},

View File

@@ -31,13 +31,68 @@ defmodule WandererApp.CachedInfo do
)
end)
Cachex.get(:ship_types_cache, type_id)
get_ship_type_from_cache_or_api(type_id)
{:ok, ship_type} ->
{:ok, ship_type}
end
end
defp get_ship_type_from_cache_or_api(type_id) do
case Cachex.get(:ship_types_cache, type_id) do
{:ok, ship_type} when not is_nil(ship_type) ->
{:ok, ship_type}
{:ok, nil} ->
case WandererApp.Esi.get_type_info(type_id) do
{:ok, info} when not is_nil(info) ->
ship_type = parse_type(type_id, info)
{:ok, group_info} = get_group_info(ship_type.group_id)
{:ok, ship_type_info} =
WandererApp.Api.ShipTypeInfo |> Ash.create(ship_type |> Map.merge(group_info))
{:ok,
ship_type_info
|> Map.take([
:type_id,
:group_id,
:group_name,
:name,
:description,
:mass,
:capacity,
:volume
])}
{:error, reason} ->
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(reason)}")
{:ok, nil}
error ->
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(error)}")
{:ok, nil}
end
end
end
def get_group_info(nil), do: {:ok, nil}
def get_group_info(group_id) do
case WandererApp.Esi.get_group_info(group_id) do
{:ok, info} when not is_nil(info) ->
{:ok, parse_group(group_id, info)}
{:error, reason} ->
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(reason)}")
{:ok, %{group_name: ""}}
error ->
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(error)}")
{:ok, %{group_name: ""}}
end
end
def get_system_static_info(solar_system_id) do
{:ok, solar_system_id} = APIUtils.parse_int(solar_system_id)
@@ -153,6 +208,25 @@ defmodule WandererApp.CachedInfo do
end
end
defp parse_group(group_id, group) do
%{
group_id: group_id,
group_name: Map.get(group, "name")
}
end
defp parse_type(type_id, type) do
%{
type_id: type_id,
name: Map.get(type, "name"),
description: Map.get(type, "description"),
group_id: Map.get(type, "group_id"),
mass: "#{Map.get(type, "mass")}",
capacity: "#{Map.get(type, "capacity")}",
volume: "#{Map.get(type, "volume")}"
}
end
defp build_jump_index() do
case get_solar_system_jumps() do
{:ok, jumps} ->

View File

@@ -331,7 +331,7 @@ defmodule WandererApp.Character do
do:
{:ok,
Enum.map(eve_ids, fn eve_id ->
Task.async(fn -> apply(WandererApp.Esi.ApiClient, method, [eve_id]) end)
Task.async(fn -> apply(WandererApp.Esi, method, [eve_id]) end)
end)
# 145000 == Timeout in milliseconds
|> Enum.map(fn task -> Task.await(task, 145_000) end)

View File

@@ -709,6 +709,7 @@ defmodule WandererApp.Character.Tracker do
end
end
# when old_alliance_id != alliance_id and is_nil(alliance_id)
defp maybe_update_alliance(
%{character_id: character_id, alliance_id: old_alliance_id} = state,
alliance_id
@@ -734,6 +735,7 @@ defmodule WandererApp.Character.Tracker do
)
state
|> Map.merge(%{alliance_id: nil})
end
defp maybe_update_alliance(
@@ -771,6 +773,7 @@ defmodule WandererApp.Character.Tracker do
)
state
|> Map.merge(%{alliance_id: alliance_id})
_error ->
Logger.error("Failed to get alliance info for #{alliance_id}")

View File

@@ -8,7 +8,8 @@ defmodule WandererApp.Character.TrackerPool do
:tracked_ids,
:uuid,
:characters,
server_online: false
server_online: false,
last_location_duration: 0
]
@name __MODULE__
@@ -23,6 +24,15 @@ defmodule WandererApp.Character.TrackerPool do
@update_info_interval :timer.minutes(2)
@update_wallet_interval :timer.minutes(10)
# Per-operation concurrency limits
# Location updates are critical and need high concurrency (100 chars in ~200ms)
# Note: This is fetched at runtime since it's configured via runtime.exs
defp location_concurrency do
Application.get_env(:wanderer_app, :location_concurrency, System.schedulers_online() * 12)
end
# Other operations can use lower concurrency
@standard_concurrency System.schedulers_online() * 2
@logger Application.compile_env(:wanderer_app, :logger)
def new(), do: __struct__()
@@ -106,14 +116,23 @@ defmodule WandererApp.Character.TrackerPool do
"server_status"
)
Process.send_after(self(), :update_online, 100)
Process.send_after(self(), :update_location, 300)
Process.send_after(self(), :update_ship, 500)
Process.send_after(self(), :update_info, 1500)
# Stagger pool startups to distribute load across multiple pools
# Critical location updates get minimal stagger (0-500ms)
# Other operations get wider stagger (0-10s) to reduce thundering herd
location_stagger = :rand.uniform(500)
online_stagger = :rand.uniform(10_000)
ship_stagger = :rand.uniform(10_000)
info_stagger = :rand.uniform(60_000)
Process.send_after(self(), :update_online, 100 + online_stagger)
Process.send_after(self(), :update_location, 300 + location_stagger)
Process.send_after(self(), :update_ship, 500 + ship_stagger)
Process.send_after(self(), :update_info, 1500 + info_stagger)
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
if WandererApp.Env.wallet_tracking_enabled?() do
Process.send_after(self(), :update_wallet, 1000)
wallet_stagger = :rand.uniform(120_000)
Process.send_after(self(), :update_wallet, 1000 + wallet_stagger)
end
{:noreply, state}
@@ -163,7 +182,7 @@ defmodule WandererApp.Character.TrackerPool do
fn character_id ->
WandererApp.Character.Tracker.update_online(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
@@ -226,7 +245,7 @@ defmodule WandererApp.Character.TrackerPool do
WandererApp.Character.Tracker.check_offline(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn
@@ -254,26 +273,52 @@ defmodule WandererApp.Character.TrackerPool do
) do
Process.send_after(self(), :update_location, @update_location_interval)
start_time = System.monotonic_time(:millisecond)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_location(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
max_concurrency: location_concurrency(),
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
|> Enum.each(fn _result -> :ok end)
# Emit telemetry for location update performance
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:wanderer_app, :tracker_pool, :location_update],
%{duration: duration, character_count: length(characters)},
%{pool_uuid: state.uuid}
)
# Warn if location updates are falling behind (taking > 800ms for 100 chars)
if duration > 800 do
Logger.warning(
"[Tracker Pool] Location updates falling behind: #{duration}ms for #{length(characters)} chars (pool: #{state.uuid})"
)
:telemetry.execute(
[:wanderer_app, :tracker_pool, :location_lag],
%{duration: duration, character_count: length(characters)},
%{pool_uuid: state.uuid}
)
end
{:noreply, %{state | last_location_duration: duration}}
rescue
e ->
Logger.error("""
[Tracker Pool] update_location => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
{:noreply, state}
end
end
def handle_info(
@@ -289,32 +334,48 @@ defmodule WandererApp.Character.TrackerPool do
:update_ship,
%{
characters: characters,
server_online: true
server_online: true,
last_location_duration: location_duration
} =
state
) do
Process.send_after(self(), :update_ship, @update_ship_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_ship(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
# Backpressure: Skip ship updates if location updates are falling behind
if location_duration > 1000 do
Logger.debug(
"[Tracker Pool] Skipping ship update due to location lag (#{location_duration}ms)"
)
|> Enum.each(fn _result -> :ok end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
:telemetry.execute(
[:wanderer_app, :tracker_pool, :ship_skipped],
%{count: 1},
%{pool_uuid: state.uuid, reason: :location_lag}
)
{:noreply, state}
else
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_ship(character_id)
end,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
|> Enum.each(fn _result -> :ok end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
end
def handle_info(
@@ -330,35 +391,51 @@ defmodule WandererApp.Character.TrackerPool do
:update_info,
%{
characters: characters,
server_online: true
server_online: true,
last_location_duration: location_duration
} =
state
) do
Process.send_after(self(), :update_info, @update_info_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_info(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
# Backpressure: Skip info updates if location updates are severely falling behind
if location_duration > 1500 do
Logger.debug(
"[Tracker Pool] Skipping info update due to location lag (#{location_duration}ms)"
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> Logger.error("Error in update_info: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_info => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
:telemetry.execute(
[:wanderer_app, :tracker_pool, :info_skipped],
%{count: 1},
%{pool_uuid: state.uuid, reason: :location_lag}
)
{:noreply, state}
else
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_info(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> Logger.error("Error in update_info: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_info => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
end
def handle_info(
@@ -387,7 +464,7 @@ defmodule WandererApp.Character.TrackerPool do
WandererApp.Character.Tracker.update_wallet(character_id)
end,
timeout: :timer.minutes(5),
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn

View File

@@ -2,6 +2,8 @@ defmodule WandererApp.Esi do
@moduledoc group: :esi
defdelegate get_server_status, to: WandererApp.Esi.ApiClient
defdelegate get_group_info(group_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_type_info(type_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_alliance_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_corporation_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_character_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient

View File

@@ -17,6 +17,17 @@ defmodule WandererApp.Esi.ApiClient do
@logger Application.compile_env(:wanderer_app, :logger)
# Pool selection for different operation types
# Character tracking operations use dedicated high-capacity pool
@character_tracking_pool WandererApp.Finch.ESI.CharacterTracking
# General ESI operations use standard pool
@general_pool WandererApp.Finch.ESI.General
# Helper function to get Req options with appropriate Finch pool
defp req_options_for_pool(pool) do
[base_url: "https://esi.evetech.net", finch: pool]
end
def get_server_status, do: do_get("/status", [], @cache_opts)
def set_autopilot_waypoint(add_to_beginning, clear_other_waypoints, destination_id, opts \\ []),
@@ -38,10 +49,13 @@ defmodule WandererApp.Esi.ApiClient do
do:
do_post_esi(
"/characters/affiliation/",
json: character_eve_ids,
params: %{
datasource: "tranquility"
}
[
json: character_eve_ids,
params: %{
datasource: "tranquility"
}
],
@character_tracking_pool
)
def get_routes_custom(hubs, origin, params),
@@ -116,7 +130,33 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "group-info-#{group_id}",
opts: [ttl: @ttl]
)
def get_group_info(group_id, opts),
do:
do_get(
"/universe/groups/#{group_id}/",
opts,
@cache_opts
)
@decorate cacheable(
cache: Cache,
key: "type-info-#{type_id}",
opts: [ttl: @ttl]
)
def get_type_info(type_id, opts),
do:
do_get(
"/universe/types/#{type_id}/",
opts,
@cache_opts
)
@decorate cacheable(
cache: Cache,
key: "alliance-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_alliance_info(eve_id, opts \\ []) do
@@ -137,7 +177,7 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "corporation-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_corporation_info(eve_id, opts \\ []) do
@@ -150,7 +190,7 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "character-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_character_info(eve_id, opts \\ []) do
@@ -206,9 +246,7 @@ defmodule WandererApp.Esi.ApiClient do
params = Keyword.get(opts, :params, %{}) |> Map.new()
search_val =
to_string(
Map.get(params, :search) || Map.get(params, "search") || ""
)
to_string(Map.get(params, :search) || Map.get(params, "search") || "")
categories_val =
to_string(
@@ -265,14 +303,18 @@ defmodule WandererApp.Esi.ApiClient do
character_id = opts |> Keyword.get(:character_id, nil)
# Use character tracking pool for character operations
pool = @character_tracking_pool
if not is_access_token_expired?(character_id) do
do_get(
path,
auth_opts,
opts |> with_refresh_token()
opts |> with_refresh_token(),
pool
)
else
do_get_retry(path, auth_opts, opts |> with_refresh_token())
do_get_retry(path, auth_opts, opts |> with_refresh_token(), :forbidden, pool)
end
end
@@ -306,19 +348,19 @@ defmodule WandererApp.Esi.ApiClient do
defp with_cache_opts(opts),
do: opts |> Keyword.merge(@cache_opts) |> Keyword.merge(cache_dir: System.tmp_dir!())
defp do_get(path, api_opts \\ [], opts \\ []) do
defp do_get(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
case Cachex.get(:api_cache, path) do
{:ok, cached_data} when not is_nil(cached_data) ->
{:ok, cached_data}
_ ->
do_get_request(path, api_opts, opts)
do_get_request(path, api_opts, opts, pool)
end
end
defp do_get_request(path, api_opts \\ [], opts \\ []) do
defp do_get_request(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
try do
@req_esi_options
req_options_for_pool(pool)
|> Req.new()
|> Req.get(
api_opts
@@ -409,12 +451,48 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{status: status, headers: headers}} ->
{:error, "Unexpected status: #{status}"}
{:error, _reason} ->
{:error, %Mint.TransportError{reason: :timeout}} ->
# Emit telemetry for pool timeout
:telemetry.execute(
[:wanderer_app, :finch, :pool_timeout],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
{:error, :pool_timeout}
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
end
{:error, "Request failed"}
end
rescue
e ->
Logger.error(Exception.message(e))
error_msg = Exception.message(e)
# Emit telemetry for pool exhaustion errors
if error_msg =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
Logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
method: "GET",
path: path,
pool: inspect(pool)
)
else
Logger.error(error_msg)
end
{:error, "Request failed"}
end
@@ -503,13 +581,13 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp do_post_esi(url, opts) do
defp do_post_esi(url, opts, pool \\ @general_pool) do
try do
req_opts =
(opts |> with_user_agent_opts() |> Keyword.merge(@retry_opts)) ++
[params: opts[:params] || []]
Req.new(@req_esi_options ++ req_opts)
Req.new(req_options_for_pool(pool) ++ req_opts)
|> Req.post(url: url)
|> case do
{:ok, %{status: status, body: body}} when status in [200, 201] ->
@@ -587,18 +665,54 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{status: status}} ->
{:error, "Unexpected status: #{status}"}
{:error, %Mint.TransportError{reason: :timeout}} ->
# Emit telemetry for pool timeout
:telemetry.execute(
[:wanderer_app, :finch, :pool_timeout],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
{:error, :pool_timeout}
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
end
{:error, reason}
end
rescue
e ->
@logger.error(Exception.message(e))
error_msg = Exception.message(e)
# Emit telemetry for pool exhaustion errors
if error_msg =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
@logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
method: "POST_ESI",
path: url,
pool: inspect(pool)
)
else
@logger.error(error_msg)
end
{:error, "Request failed"}
end
end
defp do_get_retry(path, api_opts, opts, status \\ :forbidden) do
defp do_get_retry(path, api_opts, opts, status \\ :forbidden, pool \\ @general_pool) do
refresh_token? = opts |> Keyword.get(:refresh_token?, false)
retry_count = opts |> Keyword.get(:retry_count, 0)
character_id = opts |> Keyword.get(:character_id, nil)
@@ -613,7 +727,8 @@ defmodule WandererApp.Esi.ApiClient do
do_get(
path,
api_opts |> Keyword.merge(auth_opts),
opts |> Keyword.merge(retry_count: retry_count + 1)
opts |> Keyword.merge(retry_count: retry_count + 1),
pool
)
{:error, _error} ->

View File

@@ -90,7 +90,9 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
@impl true
def handle_cast({:dispatch_events, map_id, events}, state) do
Logger.debug(fn -> "WebhookDispatcher received #{length(events)} events for map #{map_id}" end)
Logger.debug(fn ->
"WebhookDispatcher received #{length(events)} events for map #{map_id}"
end)
# Emit telemetry for batch events
:telemetry.execute(
@@ -290,7 +292,7 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
request = Finch.build(:post, url, headers, payload)
case Finch.request(request, WandererApp.Finch, timeout: 30_000) do
case Finch.request(request, WandererApp.Finch.Webhooks, timeout: 30_000) do
{:ok, %Finch.Response{status: status}} ->
{:ok, status}

View File

@@ -167,7 +167,9 @@ defmodule WandererApp.Map.Reconciler do
defp cleanup_zombie_maps([]), do: :ok
defp cleanup_zombie_maps(zombie_maps) do
Logger.warning("[Map Reconciler] Found #{length(zombie_maps)} zombie maps: #{inspect(zombie_maps)}")
Logger.warning(
"[Map Reconciler] Found #{length(zombie_maps)} zombie maps: #{inspect(zombie_maps)}"
)
Enum.each(zombie_maps, fn map_id ->
Logger.info("[Map Reconciler] Cleaning up zombie map: #{map_id}")
@@ -201,7 +203,9 @@ defmodule WandererApp.Map.Reconciler do
defp fix_orphan_maps([]), do: :ok
defp fix_orphan_maps(orphan_maps) do
Logger.warning("[Map Reconciler] Found #{length(orphan_maps)} orphan maps: #{inspect(orphan_maps)}")
Logger.warning(
"[Map Reconciler] Found #{length(orphan_maps)} orphan maps: #{inspect(orphan_maps)}"
)
Enum.each(orphan_maps, fn map_id ->
Logger.info("[Map Reconciler] Fixing orphan map: #{map_id}")
@@ -246,7 +250,10 @@ defmodule WandererApp.Map.Reconciler do
)
:error ->
Logger.warning("[Map Reconciler] Could not find pool for map #{map_id}, removing from cache")
Logger.warning(
"[Map Reconciler] Could not find pool for map #{map_id}, removing from cache"
)
Cachex.del(@cache, map_id)
end
end)

View File

@@ -90,7 +90,8 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [],
removed_signatures: [],
solar_system_id: solar_system_id,
character_id: validated_char_uuid, # Pass internal UUID here
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
}) do
@@ -176,7 +177,8 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [attrs],
removed_signatures: [],
solar_system_id: system.solar_system_id,
character_id: validated_char_uuid, # Pass internal UUID here
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
})

View File

@@ -34,28 +34,14 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track_characters(map_id, rest)
end
def update_tracked_characters(map_id) do
def invalidate_characters(map_id) do
Task.start_link(fn ->
{:ok, all_map_tracked_character_ids} =
character_ids =
map_id
|> WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_all()
|> case do
{:ok, settings} -> {:ok, settings |> Enum.map(&Map.get(&1, :character_id))}
_ -> {:ok, []}
end
|> WandererApp.Map.get_map!()
|> Map.get(:characters, [])
{:ok, actual_map_tracked_characters} =
WandererApp.Cache.lookup("maps:#{map_id}:tracked_characters", [])
characters_to_remove = actual_map_tracked_characters -- all_map_tracked_character_ids
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
characters_to_remove,
fn ids ->
(ids ++ characters_to_remove) |> Enum.uniq()
end
)
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
:ok
end)

View File

@@ -223,6 +223,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
update_connection(map_id, :update_time_status, [:time_status], connection_update, fn
%{time_status: old_time_status},
%{id: connection_id, time_status: time_status} = updated_connection ->
# Handle EOL marking cache separately
case time_status == @connection_time_status_eol do
true ->
if old_time_status != @connection_time_status_eol do
@@ -230,18 +231,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
"map_#{map_id}:conn_#{connection_id}:mark_eol_time",
DateTime.utc_now()
)
set_start_time(map_id, connection_id, DateTime.utc_now())
end
_ ->
if old_time_status == @connection_time_status_eol do
WandererApp.Cache.delete("map_#{map_id}:conn_#{connection_id}:mark_eol_time")
set_start_time(map_id, connection_id, DateTime.utc_now())
end
end
# Always reset start_time when status changes (manual override)
# This ensures user manual changes aren't immediately overridden by cleanup
if time_status != old_time_status do
# Emit telemetry for manual time status change
:telemetry.execute(
[:wanderer_app, :connection, :manual_status_change],
%{system_time: System.system_time()},
%{
map_id: map_id,
connection_id: connection_id,
old_time_status: old_time_status,
new_time_status: time_status
}
)
set_start_time(map_id, connection_id, DateTime.utc_now())
maybe_update_linked_signature_time_status(map_id, updated_connection)
end
end)
@@ -353,6 +366,25 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source_id,
solar_system_target_id
) do
# Emit telemetry for automatic time status downgrade
elapsed_minutes = DateTime.diff(DateTime.utc_now(), connection_start_time, :minute)
:telemetry.execute(
[:wanderer_app, :connection, :auto_downgrade],
%{
elapsed_minutes: elapsed_minutes,
system_time: System.system_time()
},
%{
map_id: map_id,
connection_id: connection_id,
old_time_status: time_status,
new_time_status: new_time_status,
solar_system_source: solar_system_source_id,
solar_system_target: solar_system_target_id
}
)
set_start_time(map_id, connection_id, DateTime.utc_now())
update_connection_time_status(map_id, %{

View File

@@ -29,7 +29,7 @@ defmodule WandererApp.Map.Server.Impl do
@update_presence_timeout :timer.seconds(5)
@update_characters_timeout :timer.seconds(1)
@update_tracked_characters_timeout :timer.minutes(1)
@invalidate_characters_timeout :timer.hours(1)
def new(), do: __struct__()
def new(args), do: __struct__(args)
@@ -149,8 +149,8 @@ defmodule WandererApp.Map.Server.Impl do
Process.send_after(
self(),
{:update_tracked_characters, map_id},
@update_tracked_characters_timeout
{:invalidate_characters, map_id},
@invalidate_characters_timeout
)
Process.send_after(self(), {:update_presence, map_id}, @update_presence_timeout)
@@ -302,14 +302,14 @@ defmodule WandererApp.Map.Server.Impl do
CharactersImpl.update_characters(map_id)
end
def handle_event({:update_tracked_characters, map_id} = event) do
def handle_event({:invalidate_characters, map_id} = event) do
Process.send_after(
self(),
event,
@update_tracked_characters_timeout
@invalidate_characters_timeout
)
CharactersImpl.update_tracked_characters(map_id)
CharactersImpl.invalidate_characters(map_id)
end
def handle_event({:update_presence, map_id} = event) do

View File

@@ -0,0 +1,429 @@
defmodule WandererApp.Map.SlugRecovery do
@moduledoc """
Handles automatic recovery from duplicate map slug scenarios.
This module provides functions to:
- Detect duplicate slugs in the database (including deleted maps)
- Automatically fix duplicates by renaming newer maps
- Verify and recreate unique indexes (enforced on all maps, including deleted)
- Safely handle race conditions during recovery
## Slug Uniqueness Policy
All map slugs must be unique across the entire maps_v1 table, including
deleted maps. This prevents confusion and ensures that a slug can always
unambiguously identify a specific map in the system's history.
The recovery process is designed to be:
- Idempotent (safe to run multiple times)
- Production-safe (minimal locking, fast execution)
- Observable (telemetry events for monitoring)
"""
require Logger
alias WandererApp.Repo
@doc """
Recovers from a duplicate slug scenario for a specific slug.
This function:
1. Finds all maps with the given slug (including deleted)
2. Keeps the oldest map with the original slug
3. Renames newer duplicates with numeric suffixes
4. Verifies the unique index exists
Returns:
- `{:ok, result}` - Recovery successful
- `{:error, reason}` - Recovery failed
## Examples
iex> recover_duplicate_slug("home-2")
{:ok, %{fixed_count: 1, kept_map_id: "...", renamed_maps: [...]}}
"""
def recover_duplicate_slug(slug) do
start_time = System.monotonic_time(:millisecond)
Logger.warning("Starting slug recovery for '#{slug}'",
slug: slug,
operation: :recover_duplicate_slug
)
:telemetry.execute(
[:wanderer_app, :map, :slug_recovery, :start],
%{system_time: System.system_time()},
%{slug: slug, operation: :recover_duplicate_slug}
)
result =
Repo.transaction(fn ->
# Find all maps with this slug (including deleted), ordered by insertion time
duplicates = find_duplicate_maps(slug)
case duplicates do
[] ->
Logger.info("No maps found with slug '#{slug}' during recovery")
%{fixed_count: 0, kept_map_id: nil, renamed_maps: []}
[_single_map] ->
Logger.info("Only one map found with slug '#{slug}', no recovery needed")
%{fixed_count: 0, kept_map_id: nil, renamed_maps: []}
[kept_map | maps_to_rename] ->
# Convert binary UUID to string for consistency
kept_map_id_str =
if is_binary(kept_map.id), do: Ecto.UUID.load!(kept_map.id), else: kept_map.id
Logger.warning(
"Found #{length(maps_to_rename)} duplicate maps for slug '#{slug}', fixing...",
slug: slug,
kept_map_id: kept_map_id_str,
duplicate_count: length(maps_to_rename)
)
# Rename the duplicate maps
renamed_maps =
maps_to_rename
|> Enum.with_index(2)
|> Enum.map(fn {map, index} ->
new_slug = generate_unique_slug(slug, index)
rename_map(map, new_slug)
end)
%{
fixed_count: length(renamed_maps),
kept_map_id: kept_map_id_str,
renamed_maps: renamed_maps
}
end
end)
case result do
{:ok, recovery_result} ->
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:wanderer_app, :map, :slug_recovery, :complete],
%{
duration_ms: duration,
fixed_count: recovery_result.fixed_count,
system_time: System.system_time()
},
%{slug: slug, result: recovery_result}
)
Logger.info("Slug recovery completed successfully",
slug: slug,
fixed_count: recovery_result.fixed_count,
duration_ms: duration
)
{:ok, recovery_result}
{:error, reason} = error ->
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:wanderer_app, :map, :slug_recovery, :error],
%{duration_ms: duration, system_time: System.system_time()},
%{slug: slug, error: inspect(reason)}
)
Logger.error("Slug recovery failed",
slug: slug,
error: inspect(reason),
duration_ms: duration
)
error
end
end
@doc """
Verifies that the unique index on map slugs exists.
If missing, attempts to create it (after fixing any duplicates).
Returns:
- `{:ok, :exists}` - Index already exists
- `{:ok, :created}` - Index was created
- `{:error, reason}` - Failed to create index
"""
def verify_unique_index do
Logger.debug("Verifying unique index on maps_v1.slug")
# Check if the index exists
index_query = """
SELECT 1
FROM pg_indexes
WHERE tablename = 'maps_v1'
AND indexname = 'maps_v1_unique_slug_index'
LIMIT 1
"""
case Repo.query(index_query, []) do
{:ok, %{rows: [[1]]}} ->
Logger.debug("Unique index exists")
{:ok, :exists}
{:ok, %{rows: []}} ->
Logger.warning("Unique index missing, attempting to create")
create_unique_index()
{:error, reason} ->
Logger.error("Failed to check for unique index", error: inspect(reason))
{:error, reason}
end
end
@doc """
Performs a full recovery scan of all maps, fixing any duplicates found.
Processes both deleted and non-deleted maps.
This function will:
1. Drop the unique index if it exists (to allow fixing duplicates)
2. Find and fix all duplicate slugs
3. Return statistics about the recovery
Note: This function does NOT recreate the index. Call `verify_unique_index/0`
after this function completes to ensure the index is recreated.
This is a more expensive operation and should be run:
- During maintenance windows
- After detecting multiple duplicate slug errors
- As part of deployment verification
Returns:
- `{:ok, stats}` - Recovery completed with statistics
- `{:error, reason}` - Recovery failed
"""
def recover_all_duplicates do
Logger.info("Starting full duplicate slug recovery (including deleted maps)")
start_time = System.monotonic_time(:millisecond)
:telemetry.execute(
[:wanderer_app, :map, :full_recovery, :start],
%{system_time: System.system_time()},
%{}
)
# Drop the unique index if it exists to allow fixing duplicates
drop_unique_index_if_exists()
# Find all slugs that have duplicates (including deleted maps)
duplicate_slugs_query = """
SELECT slug, COUNT(*) as count
FROM maps_v1
GROUP BY slug
HAVING COUNT(*) > 1
"""
case Repo.query(duplicate_slugs_query, []) do
{:ok, %{rows: []}} ->
Logger.info("No duplicate slugs found")
{:ok, %{total_slugs_fixed: 0, total_maps_renamed: 0}}
{:ok, %{rows: duplicate_rows}} ->
Logger.warning("Found #{length(duplicate_rows)} slugs with duplicates",
duplicate_count: length(duplicate_rows)
)
# Fix each duplicate slug
results =
Enum.map(duplicate_rows, fn [slug, _count] ->
case recover_duplicate_slug(slug) do
{:ok, result} -> result
{:error, _} -> %{fixed_count: 0, kept_map_id: nil, renamed_maps: []}
end
end)
stats = %{
total_slugs_fixed: length(results),
total_maps_renamed: Enum.sum(Enum.map(results, & &1.fixed_count))
}
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:wanderer_app, :map, :full_recovery, :complete],
%{
duration_ms: duration,
slugs_fixed: stats.total_slugs_fixed,
maps_renamed: stats.total_maps_renamed,
system_time: System.system_time()
},
%{stats: stats}
)
Logger.info("Full recovery completed",
stats: stats,
duration_ms: duration
)
{:ok, stats}
{:error, reason} = error ->
Logger.error("Failed to query for duplicates", error: inspect(reason))
error
end
end
# Private functions
defp find_duplicate_maps(slug) do
# Find all maps (including deleted) with this slug
query = """
SELECT id, name, slug, deleted, inserted_at
FROM maps_v1
WHERE slug = $1
ORDER BY inserted_at ASC
"""
case Repo.query(query, [slug]) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [id, name, slug, deleted, inserted_at] ->
%{id: id, name: name, slug: slug, deleted: deleted, inserted_at: inserted_at}
end)
{:error, reason} ->
Logger.error("Failed to query for duplicate maps",
slug: slug,
error: inspect(reason)
)
[]
end
end
defp rename_map(map, new_slug) do
# Convert binary UUID to string for logging
map_id_str = if is_binary(map.id), do: Ecto.UUID.load!(map.id), else: map.id
Logger.info("Renaming map #{map_id_str} from '#{map.slug}' to '#{new_slug}'",
map_id: map_id_str,
old_slug: map.slug,
new_slug: new_slug,
deleted: map.deleted
)
update_query = """
UPDATE maps_v1
SET slug = $1, updated_at = NOW()
WHERE id = $2
"""
case Repo.query(update_query, [new_slug, map.id]) do
{:ok, _} ->
Logger.info("Successfully renamed map #{map_id_str} to '#{new_slug}'")
%{
map_id: map_id_str,
old_slug: map.slug,
new_slug: new_slug,
map_name: map.name,
deleted: map.deleted
}
{:error, reason} ->
map_id_str = if is_binary(map.id), do: Ecto.UUID.load!(map.id), else: map.id
Logger.error("Failed to rename map #{map_id_str}",
map_id: map_id_str,
old_slug: map.slug,
new_slug: new_slug,
error: inspect(reason)
)
%{
map_id: map_id_str,
old_slug: map.slug,
new_slug: nil,
error: reason
}
end
end
defp generate_unique_slug(base_slug, index) do
candidate = "#{base_slug}-#{index}"
# Verify this slug is actually unique (check all maps, including deleted)
query = "SELECT 1 FROM maps_v1 WHERE slug = $1 LIMIT 1"
case Repo.query(query, [candidate]) do
{:ok, %{rows: []}} ->
candidate
{:ok, %{rows: [[1]]}} ->
# This slug is taken, try the next one
generate_unique_slug(base_slug, index + 1)
{:error, _} ->
# On error, be conservative and try next number
generate_unique_slug(base_slug, index + 1)
end
end
defp create_unique_index do
Logger.warning("Creating unique index on maps_v1.slug")
# Create index on all maps (including deleted ones)
# This enforces slug uniqueness across all maps regardless of deletion status
create_index_query = """
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS maps_v1_unique_slug_index
ON maps_v1 (slug)
"""
case Repo.query(create_index_query, []) do
{:ok, _} ->
Logger.info("Successfully created unique index (includes deleted maps)")
:telemetry.execute(
[:wanderer_app, :map, :index_created],
%{system_time: System.system_time()},
%{index_name: "maps_v1_unique_slug_index"}
)
{:ok, :created}
{:error, reason} ->
Logger.error("Failed to create unique index", error: inspect(reason))
{:error, reason}
end
end
defp drop_unique_index_if_exists do
Logger.debug("Checking if unique index exists before recovery")
check_query = """
SELECT 1
FROM pg_indexes
WHERE tablename = 'maps_v1'
AND indexname = 'maps_v1_unique_slug_index'
LIMIT 1
"""
case Repo.query(check_query, []) do
{:ok, %{rows: [[1]]}} ->
Logger.info("Dropping unique index to allow duplicate recovery")
drop_query = "DROP INDEX IF EXISTS maps_v1_unique_slug_index"
case Repo.query(drop_query, []) do
{:ok, _} ->
Logger.info("Successfully dropped unique index")
:ok
{:error, reason} ->
Logger.warning("Failed to drop unique index", error: inspect(reason))
:ok
end
{:ok, %{rows: []}} ->
Logger.debug("Unique index does not exist, no need to drop")
:ok
{:error, reason} ->
Logger.warning("Failed to check for unique index", error: inspect(reason))
:ok
end
end
end

View File

@@ -23,10 +23,12 @@ defmodule WandererApp.Release do
IO.puts("Run migrations..")
prepare()
for repo <- repos() do
for repo <- repos do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
run_post_migration_tasks()
:init.stop()
end
@@ -76,6 +78,8 @@ defmodule WandererApp.Release do
Enum.each(streaks, fn {repo, up_to_version} ->
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, to: up_to_version))
end)
run_post_migration_tasks()
end
defp migration_streaks(pending_migrations) do
@@ -215,4 +219,40 @@ defmodule WandererApp.Release do
IO.puts("Starting repos..")
Enum.each(repos(), & &1.start_link(pool_size: 2))
end
defp run_post_migration_tasks do
IO.puts("Running post-migration tasks..")
# Recover any duplicate map slugs
IO.puts("Checking for duplicate map slugs..")
case WandererApp.Map.SlugRecovery.recover_all_duplicates() do
{:ok, %{total_slugs_fixed: 0}} ->
IO.puts("No duplicate slugs found.")
{:ok, %{total_slugs_fixed: count, total_maps_renamed: renamed}} ->
IO.puts("Successfully fixed #{count} duplicate slug(s), renamed #{renamed} map(s).")
{:error, reason} ->
IO.puts("Warning: Failed to recover duplicate slugs: #{inspect(reason)}")
IO.puts("Application will continue, but you may need to manually fix duplicate slugs.")
end
# Ensure the unique index exists after recovery
IO.puts("Verifying unique index on map slugs..")
case WandererApp.Map.SlugRecovery.verify_unique_index() do
{:ok, :exists} ->
IO.puts("Unique index already exists.")
{:ok, :created} ->
IO.puts("Successfully created unique index.")
{:error, reason} ->
IO.puts("Warning: Failed to verify/create unique index: #{inspect(reason)}")
IO.puts("You may need to manually create the index.")
end
IO.puts("Post-migration tasks completed.")
end
end

View File

@@ -1,6 +1,8 @@
defmodule WandererApp.MapRepo do
use WandererApp, :repository
require Logger
@default_map_options %{
"layout" => "left_to_right",
"store_custom_labels" => "false",
@@ -34,32 +36,38 @@ defmodule WandererApp.MapRepo do
Safely retrieves a map by slug, handling the case where multiple maps
with the same slug exist (database integrity issue).
When duplicates are detected, automatically triggers recovery to fix them
and retries the query once.
Returns:
- `{:ok, map}` - Single map found
- `{:error, :multiple_results}` - Multiple maps found (logs error)
- `{:error, :multiple_results}` - Multiple maps found (after recovery attempt)
- `{:error, :not_found}` - No map found
- `{:error, reason}` - Other error
"""
def get_map_by_slug_safely(slug) do
def get_map_by_slug_safely(slug, retry_count \\ 0) do
try do
map = WandererApp.Api.Map.get_map_by_slug!(slug)
{:ok, map}
rescue
error in Ash.Error.Invalid.MultipleResults ->
Logger.error("Multiple maps found with slug '#{slug}' - database integrity issue",
slug: slug,
error: inspect(error)
)
handle_multiple_results(slug, error, retry_count)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :map, :duplicate_slug_detected],
%{count: 1},
%{slug: slug, operation: :get_by_slug}
)
error in Ash.Error.Invalid ->
# Check if this Invalid error contains a MultipleResults error
case find_multiple_results_error(error) do
{:ok, multiple_results_error} ->
handle_multiple_results(slug, multiple_results_error, retry_count)
# Return error - caller should handle this appropriately
{:error, :multiple_results}
:error ->
# Some other Invalid error
Logger.error("Error retrieving map by slug",
slug: slug,
error: inspect(error)
)
{:error, :unknown_error}
end
error in Ash.Error.Query.NotFound ->
Logger.debug("Map not found with slug: #{slug}")
@@ -75,6 +83,65 @@ defmodule WandererApp.MapRepo do
end
end
# Helper function to handle multiple results errors with automatic recovery
defp handle_multiple_results(slug, error, retry_count) do
count = Map.get(error, :count, 2)
Logger.error("Multiple maps found with slug '#{slug}' - triggering automatic recovery",
slug: slug,
count: count,
retry_count: retry_count,
error: inspect(error)
)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :map, :duplicate_slug_detected],
%{count: count, retry_count: retry_count},
%{slug: slug, operation: :get_by_slug}
)
# Attempt automatic recovery if this is the first try
if retry_count == 0 do
case WandererApp.Map.SlugRecovery.recover_duplicate_slug(slug) do
{:ok, recovery_result} ->
Logger.info("Successfully recovered duplicate slug '#{slug}', retrying query",
slug: slug,
fixed_count: recovery_result.fixed_count
)
# Retry the query once after recovery
get_map_by_slug_safely(slug, retry_count + 1)
{:error, reason} ->
Logger.error("Failed to recover duplicate slug '#{slug}'",
slug: slug,
error: inspect(reason)
)
{:error, :multiple_results}
end
else
# Already retried once, give up
Logger.error(
"Multiple maps still found with slug '#{slug}' after recovery attempt",
slug: slug,
count: count
)
{:error, :multiple_results}
end
end
# Helper function to check if an Ash.Error.Invalid contains a MultipleResults error
defp find_multiple_results_error(%Ash.Error.Invalid{errors: errors}) do
errors
|> Enum.find_value(:error, fn
%Ash.Error.Invalid.MultipleResults{} = mr_error -> {:ok, mr_error}
_ -> false
end)
end
def load_relationships(map, []), do: {:ok, map}
def load_relationships(map, relationships), do: map |> Ash.load(relationships)

View File

@@ -5,7 +5,8 @@ defmodule WandererApp.Test.DDRT do
"""
@callback init_tree(String.t(), map()) :: :ok | {:error, term()}
@callback insert({integer(), any()} | list({integer(), any()}), String.t()) :: {:ok, map()} | {:error, term()}
@callback insert({integer(), any()} | list({integer(), any()}), String.t()) ::
{:ok, map()} | {:error, term()}
@callback update(integer(), any(), String.t()) :: {:ok, map()} | {:error, term()}
@callback delete(integer() | [integer()], String.t()) :: {:ok, map()} | {:error, term()}
@callback query(any(), String.t()) :: {:ok, [any()]} | {:error, term()}

View File

@@ -49,7 +49,7 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
WandererApp.Cache.put(
"eve_auth_#{params[:state]}",
[with_wallet: with_wallet, is_admin?: is_admin?],
ttl: :timer.minutes(15)
ttl: :timer.minutes(30)
)
opts = oauth_client_options_from_conn(conn, with_wallet, is_admin?)
@@ -66,17 +66,22 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
Handles the callback from Eve.
"""
def handle_callback!(%Plug.Conn{params: %{"code" => code, "state" => state}} = conn) do
opts =
WandererApp.Cache.get("eve_auth_#{state}")
case WandererApp.Cache.get("eve_auth_#{state}") do
nil ->
# Cache expired or invalid state - redirect to welcome page
conn
|> redirect!("/welcome")
params = [code: code]
opts ->
params = [code: code]
case WandererApp.Ueberauth.Strategy.Eve.OAuth.get_access_token(params, opts) do
{:ok, token} ->
fetch_user(conn, token)
case WandererApp.Ueberauth.Strategy.Eve.OAuth.get_access_token(params, opts) do
{:ok, token} ->
fetch_user(conn, token)
{:error, {error_code, error_description}} ->
set_errors!(conn, [error(error_code, error_description)])
{:error, {error_code, error_description}} ->
set_errors!(conn, [error(error_code, error_description)])
end
end
end

View File

@@ -34,7 +34,12 @@
</div>
</div>
<div class="navbar-center">
<a href="/" class="!opacity-0 text-[24px] text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]">Wanderer</a>
<a
href="/"
class="!opacity-0 text-[24px] text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]"
>
Wanderer
</a>
</div>
<div class="navbar-end"></div>
</navbar>
@@ -44,10 +49,13 @@
<!--Footer-->
<footer class="!z-10 w-full pt-8 pb-4 text-sm text-center fade-in flex justify-center items-center">
<div class="flex flex-col justify-center items-center">
<a target="_blank" rel="noopener noreferrer" href="https://www.eveonline.com/partners"><img src="/images/eo_pp.png" style="width: 300px;" alt="Eve Online Partnership Program"></a>
<a target="_blank" rel="noopener noreferrer" href="https://www.eveonline.com/partners">
<img src="/images/eo_pp.png" style="width: 300px;" alt="Eve Online Partnership Program" />
</a>
<div class="text-stone-400 no-underline hover:no-underline [text-shadow:0_0px_4px_rgba(0,0,0,0.8)]">
All <a href="/license">EVE related materials</a> are property of <a href="https://www.ccpgames.com">CCP Games</a>
&copy; {Date.utc_today().year} Wanderer Industries.
All <a href="/license">EVE related materials</a>
are property of <a href="https://www.ccpgames.com">CCP Games</a>
&copy; {Date.utc_today().year} Wanderer Industries.
</div>
</div>
</footer>

View File

@@ -34,5 +34,4 @@
<.new_version_banner app_version={@app_version} enabled={@map_subscriptions_enabled?} />
</div>
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />

View File

@@ -42,8 +42,12 @@
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">{first_part}</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">{second_part || ""}</p>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part || ""}
</p>
</div>
</div>
</.link>

View File

@@ -98,7 +98,10 @@
</div>
<div class="w-full justify-end">
<ul class="flex flex-wrap items-center p-0 m-0">
<li :for={tag <- @post.tags} class="inline-flex rounded-[35px] bg-primary px-1 text-white">
<li
:for={tag <- @post.tags}
class="inline-flex rounded-[35px] bg-primary px-1 text-white"
>
<a href="#">
<div class="badge badge-outline text-lime-400 rounded-none border-none text-xl">
#{tag}

View File

@@ -15,24 +15,63 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
description: "A cosmic signature scanned in an EVE Online solar system",
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string, format: :uuid, description: "Unique signature identifier"},
solar_system_id: %OpenApiSpex.Schema{type: :integer, description: "EVE Online solar system ID"},
eve_id: %OpenApiSpex.Schema{type: :string, description: "In-game signature ID (e.g., ABC-123)"},
id: %OpenApiSpex.Schema{
type: :string,
format: :uuid,
description: "Unique signature identifier"
},
solar_system_id: %OpenApiSpex.Schema{
type: :integer,
description: "EVE Online solar system ID"
},
eve_id: %OpenApiSpex.Schema{
type: :string,
description: "In-game signature ID (e.g., ABC-123)"
},
character_eve_id: %OpenApiSpex.Schema{
type: :string,
description: "EVE character ID who scanned/updated this signature. Must be a valid character in the database. If not provided, defaults to the map owner's character.",
description:
"EVE character ID who scanned/updated this signature. Must be a valid character in the database. If not provided, defaults to the map owner's character.",
nullable: true
},
name: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature name"},
description: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Additional notes"},
description: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Additional notes"
},
type: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature type"},
linked_system_id: %OpenApiSpex.Schema{type: :integer, nullable: true, description: "Connected solar system ID for wormholes"},
kind: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature kind (e.g., cosmic_signature)"},
group: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature group (e.g., wormhole, data, relic)"},
custom_info: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Custom metadata"},
linked_system_id: %OpenApiSpex.Schema{
type: :integer,
nullable: true,
description: "Connected solar system ID for wormholes"
},
kind: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Signature kind (e.g., cosmic_signature)"
},
group: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Signature group (e.g., wormhole, data, relic)"
},
custom_info: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Custom metadata"
},
updated: %OpenApiSpex.Schema{type: :integer, nullable: true, description: "Update counter"},
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Creation timestamp"},
updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Last update timestamp"}
inserted_at: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Creation timestamp"
},
updated_at: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Last update timestamp"
}
},
required: [
:id,
@@ -178,7 +217,8 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
description:
"Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
}
},
example: %{error: "invalid_character"}

View File

@@ -186,14 +186,18 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
defp get_map_identifier(conn) do
# 1. Check path params (e.g., /api/v1/maps/:map_identifier/systems)
case conn.params["map_identifier"] do
id when is_binary(id) and id != "" -> id
id when is_binary(id) and id != "" ->
id
_ ->
# 2. Check request body for map_id (JSON:API format)
case conn.body_params do
%{"data" => %{"attributes" => %{"map_id" => map_id}}} when is_binary(map_id) and map_id != "" ->
%{"data" => %{"attributes" => %{"map_id" => map_id}}}
when is_binary(map_id) and map_id != "" ->
map_id
%{"data" => %{"relationships" => %{"map" => %{"data" => %{"id" => map_id}}}}} when is_binary(map_id) and map_id != "" ->
%{"data" => %{"relationships" => %{"map" => %{"data" => %{"id" => map_id}}}}}
when is_binary(map_id) and map_id != "" ->
map_id
# 3. Check flat body params (non-JSON:API format)

View File

@@ -336,7 +336,7 @@
label="Valid"
options={Enum.map(@valid_types, fn valid_type -> {valid_type.label, valid_type.id} end)}
/>
<!-- API Key Section with grid layout -->
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">

View File

@@ -148,13 +148,13 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
end
end
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
{:noreply, socket}
end
@@ -200,15 +200,15 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
_ -> nil
end
WandererApp.User.ActivityTracker.track_map_event(:map_connection_updated, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer(),
key: key_atom,
value: value
})
WandererApp.User.ActivityTracker.track_map_event(:map_connection_updated, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer(),
key: key_atom,
value: value
})
apply(WandererApp.Map.Server, method_atom, [
map_id,

View File

@@ -78,7 +78,36 @@ defmodule WandererAppWeb.Telemetry do
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
summary("vm.total_run_queue_lengths.io"),
# Finch Pool Metrics
counter("wanderer_app.finch.pool_exhausted.count",
tags: [:pool, :method],
description: "Count of Finch pool exhaustion errors"
),
counter("wanderer_app.finch.pool_timeout.count",
tags: [:pool, :method],
description: "Count of Finch pool timeout errors"
),
# Character Tracker Pool Metrics
summary("wanderer_app.tracker_pool.location_update.duration",
unit: :millisecond,
tags: [:pool_uuid],
description: "Time taken to update all character locations in a pool"
),
counter("wanderer_app.tracker_pool.location_lag.count",
tags: [:pool_uuid],
description: "Count of location updates falling behind (>800ms)"
),
counter("wanderer_app.tracker_pool.ship_skipped.count",
tags: [:pool_uuid, :reason],
description: "Count of ship updates skipped due to backpressure"
),
counter("wanderer_app.tracker_pool.info_skipped.count",
tags: [:pool_uuid, :reason],
description: "Count of info updates skipped due to backpressure"
)
]
end

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.84.35"
@version "1.85.3"
def project do
[

View File

@@ -133,7 +133,12 @@ defmodule WandererApp.Repo.Migrations.FixDuplicateMapSlugs do
{:ok, %{rows: [[false]]}} ->
IO.puts("Creating unique index on slug...")
create_if_not_exists index(:maps_v1, [:slug], unique: true, name: :maps_v1_unique_slug_index)
create_if_not_exists index(:maps_v1, [:slug],
unique: true,
name: :maps_v1_unique_slug_index
)
IO.puts("✓ Index created successfully!")
{:error, error} ->

View File

@@ -64,6 +64,7 @@ defmodule WandererApp.Repo.Migrations.EnsureNoDuplicateMapSlugs do
case repo().query(duplicates_query, []) do
{:ok, %{rows: [[count]]}} ->
count
{:error, error} ->
IO.puts("Error counting duplicates: #{inspect(error)}")
0
@@ -157,6 +158,7 @@ defmodule WandererApp.Repo.Migrations.EnsureNoDuplicateMapSlugs do
case repo().query!(check_query, [candidate]) do
%{rows: [[0]]} ->
candidate
%{rows: [[_count]]} ->
# Try next number
generate_unique_slug(base_slug, n + 1)

View File

@@ -0,0 +1,196 @@
defmodule WandererApp.Repo.Migrations.UpdateMapSlugIndexIncludeDeleted do
@moduledoc """
Updates the unique index on maps_v1.slug to include deleted maps.
Previously, the index only enforced uniqueness on non-deleted maps:
WHERE deleted = false
This migration updates it to enforce uniqueness across ALL maps,
including deleted ones. This prevents confusion and ensures that a
slug can always unambiguously identify a specific map in the system's history.
The migration:
1. Checks for any duplicate slugs (including deleted maps)
2. Fixes duplicates by renaming newer maps
3. Drops the old index (with WHERE clause)
4. Creates new index without WHERE clause (applies to all rows)
"""
use Ecto.Migration
require Logger
def up do
IO.puts("\n=== Updating Map Slug Index to Include Deleted Maps ===\n")
# Step 1: Check for duplicates across ALL maps (including deleted)
duplicate_count = count_all_duplicates()
if duplicate_count > 0 do
IO.puts("Found #{duplicate_count} duplicate slug(s) across all maps (including deleted)")
IO.puts("Fixing duplicates before updating index...\n")
# Step 2: Drop existing index
drop_existing_index()
# Step 3: Fix all duplicates (including deleted maps)
fix_all_duplicate_slugs()
# Step 4: Create new index without WHERE clause
create_new_index()
else
IO.puts("No duplicates found - updating index...\n")
# Just update the index
drop_existing_index()
create_new_index()
end
# Step 5: Verify no duplicates remain
verify_no_duplicates()
IO.puts("\n=== Migration completed successfully! ===\n")
end
def down do
IO.puts("\n=== Reverting Map Slug Index Update ===\n")
# Drop the new index
execute("DROP INDEX IF EXISTS maps_v1_unique_slug_index")
# Recreate the old index with WHERE clause
create_if_not_exists(
index(:maps_v1, [:slug],
unique: true,
name: :maps_v1_unique_slug_index,
where: "deleted = false"
)
)
IO.puts("✓ Reverted to index with WHERE deleted = false clause")
end
defp count_all_duplicates do
duplicates_query = """
SELECT COUNT(*) as duplicate_count
FROM (
SELECT slug
FROM maps_v1
GROUP BY slug
HAVING COUNT(*) > 1
) duplicates
"""
case repo().query(duplicates_query, []) do
{:ok, %{rows: [[count]]}} ->
count
{:error, error} ->
IO.puts("Error counting duplicates: #{inspect(error)}")
0
end
end
defp drop_existing_index do
IO.puts("Dropping existing unique index...")
execute("DROP INDEX IF EXISTS maps_v1_unique_slug_index")
IO.puts("✓ Old index dropped\n")
end
defp fix_all_duplicate_slugs do
# Get all duplicate slugs across ALL maps (including deleted)
duplicates_query = """
SELECT
slug,
array_agg(id::text ORDER BY inserted_at ASC, id ASC) as ids,
array_agg(name ORDER BY inserted_at ASC, id ASC) as names,
array_agg(deleted ORDER BY inserted_at ASC, id ASC) as deleted_flags
FROM maps_v1
GROUP BY slug
HAVING COUNT(*) > 1
ORDER BY slug
"""
case repo().query(duplicates_query, []) do
{:ok, %{rows: rows}} when length(rows) > 0 ->
IO.puts("Fixing #{length(rows)} duplicate slug(s)...\n")
Enum.each(rows, fn [slug, ids, names, deleted_flags] ->
IO.puts(" Processing: '#{slug}' (#{length(ids)} duplicates)")
# Keep the first one (oldest by inserted_at), rename the rest
[keep_id | rename_ids] = ids
[keep_name | rename_names] = names
[keep_deleted | rename_deleted_flags] = deleted_flags
deleted_str = if keep_deleted, do: " [DELETED]", else: ""
IO.puts(" ✓ Keeping: #{keep_id} - '#{keep_name}'#{deleted_str}")
# Rename duplicates
rename_ids
|> Enum.zip(rename_names)
|> Enum.zip(rename_deleted_flags)
|> Enum.with_index(2)
|> Enum.each(fn {{{id_string, name}, is_deleted}, n} ->
new_slug = generate_unique_slug(slug, n)
# Use parameterized query for safety
update_query = "UPDATE maps_v1 SET slug = $1 WHERE id::text = $2"
repo().query!(update_query, [new_slug, id_string])
deleted_str = if is_deleted, do: " [DELETED]", else: ""
IO.puts(" → Renamed: #{id_string} - '#{name}'#{deleted_str} to '#{new_slug}'")
end)
end)
IO.puts("\n✓ All duplicate slugs fixed!\n")
{:ok, %{rows: []}} ->
IO.puts("No duplicate slugs to fix\n")
{:error, error} ->
IO.puts("Error finding duplicates: #{inspect(error)}")
raise "Failed to query duplicate slugs: #{inspect(error)}"
end
end
defp generate_unique_slug(base_slug, n) when n >= 2 do
candidate = "#{base_slug}-#{n}"
# Check if this slug already exists across ALL maps (including deleted)
check_query = "SELECT COUNT(*) FROM maps_v1 WHERE slug = $1"
case repo().query!(check_query, [candidate]) do
%{rows: [[0]]} ->
candidate
%{rows: [[_count]]} ->
# Try next number
generate_unique_slug(base_slug, n + 1)
end
end
defp create_new_index do
IO.puts("Creating new unique index (includes deleted maps)...")
create_if_not_exists(
index(:maps_v1, [:slug],
unique: true,
name: :maps_v1_unique_slug_index
)
)
IO.puts("✓ New index created successfully!\n")
end
defp verify_no_duplicates do
IO.puts("Verifying no duplicates remain...")
remaining_duplicates = count_all_duplicates()
if remaining_duplicates > 0 do
IO.puts("❌ ERROR: #{remaining_duplicates} duplicate(s) still exist!")
raise "Migration failed: duplicates still exist after cleanup"
else
IO.puts("✓ Verification passed: No duplicates found")
end
end
end

View File

@@ -19,10 +19,11 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
use WandererApp.DataCase, async: false
import WandererApp.MapTestHelpers
alias WandererApp.Map.Server.CharactersImpl
alias WandererApp.Map.Server.SystemsImpl
@test_map_id 999_999_001
@test_character_eve_id 2_123_456_789
# EVE Online solar system IDs for testing
@@ -32,8 +33,11 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
@system_rens 30_002_510
setup do
# Clean up any existing test data
cleanup_test_data()
# Setup system static info cache for test systems
setup_system_static_info_cache()
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Create test user (let Ash generate the ID)
user = create_user(%{name: "Test User", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
@@ -48,150 +52,45 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
})
# Create test map
# Note: scope: :all is used because :none prevents system addition
# (is_connection_valid returns false for :none scope)
map = create_map(%{
id: @test_map_id,
name: "Test Char Track",
slug: "test-char-tracking-#{:rand.uniform(1_000_000)}",
owner_id: character.id,
scope: :none,
scope: :all,
only_tracked_characters: false
})
on_exit(fn ->
cleanup_test_data()
cleanup_test_data(map.id)
end)
{:ok, user: user, character: character, map: map}
end
defp cleanup_test_data do
# Note: We can't clean up character-specific caches in setup
# because we don't have the character.id yet. Tests will clean
# up their own caches in on_exit if needed.
# Clean up map-level presence tracking
WandererApp.Cache.delete("map_#{@test_map_id}:presence_character_ids")
end
defp cleanup_character_caches(character_id) do
# Clean up character location caches
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:structure_id")
# Clean up character cache
if Cachex.exists?(:character_cache, character_id) do
Cachex.del(:character_cache, character_id)
end
# Clean up character state cache
if Cachex.exists?(:character_state_cache, character_id) do
Cachex.del(:character_state_cache, character_id)
end
end
defp set_character_location(character_id, solar_system_id, opts \\ []) do
"""
Helper to simulate character location update in cache.
This mimics what the Character.Tracker does when it polls ESI.
"""
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship_type_id = opts[:ship_type_id] || 670 # Capsule
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)
# Update character cache (mimics Character.update_character/2)
character_data = Map.merge(existing_character, %{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship_type_id: ship_type_id,
updated_at: DateTime.utc_now()
})
Cachex.put(:character_cache, character_id, character_data)
end
defp ensure_map_started(map_id) do
"""
Ensure the map server is started for the given map.
This is required for character updates to work.
"""
case WandererApp.Map.Manager.start_map(map_id) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
other -> other
end
end
defp add_character_to_map_presence(map_id, character_id) do
"""
Helper to add character to map's presence list.
This mimics what PresenceGracePeriodManager does.
"""
{:ok, current_chars} = WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
updated_chars = Enum.uniq([character_id | current_chars])
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", updated_chars)
end
defp get_map_systems(map_id) do
"""
Helper to get all systems currently on the map.
"""
case WandererApp.Map.get_map_state(map_id) do
{:ok, %{map: %{systems: systems}}} when is_map(systems) ->
Map.values(systems)
{:ok, _} ->
[]
end
end
defp system_on_map?(map_id, solar_system_id) do
"""
Check if a specific system is on the map.
"""
systems = get_map_systems(map_id)
Enum.any?(systems, fn sys -> sys.solar_system_id == solar_system_id end)
end
defp wait_for_system_on_map(map_id, solar_system_id, timeout \\ 2000) do
"""
Wait for a system to appear on the map (for async operations).
"""
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
if system_on_map?(map_id, solar_system_id) do
{:ok, true}
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(50)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, true} -> true
{:error, :timeout} -> false
end
end
# Note: Helper functions moved to WandererApp.MapTestHelpers
# Functions available via import:
# - setup_ddrt_mocks/0
# - setup_system_static_info_cache/0
# - set_character_location/3
# - ensure_map_started/1
# - wait_for_map_started/2
# - add_character_to_map_presence/2
# - get_map_systems/1
# - system_on_map?/2
# - wait_for_system_on_map/3
# - cleanup_character_caches/2
# - cleanup_test_data/1
describe "Basic character location tracking" do
@tag :skip
@tag :integration
test "character location update adds system to map", %{map: map, character: character} do
# This test verifies the basic flow:
# 1. Character starts tracking on a map
# 2. Character location is updated in cache
# 1. Character starts tracking on a map at Jita
# 2. Character moves to Amarr
# 3. update_characters() is called
# 4. System is added to the map
# 4. Both systems are added to the map
# Setup: Ensure map is started
ensure_map_started(map.id)
@@ -199,24 +98,37 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Setup: Add character to presence
add_character_to_map_presence(map.id, character.id)
# Setup: Set character location
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
# Setup: Set start_solar_system_id (this happens when tracking starts)
# Note: The start system is NOT added until the character moves
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Execute: Run character update
# Execute: First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
# Verify: Jita should be added to the map
# Verify: Jita should NOT be on map yet (design: start position not added)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Execute: Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify: Both systems should now be on map
assert wait_for_system_on_map(map.id, @system_jita),
"Jita should have been added to map when character tracking started"
"Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr should be added as the new location"
end
@tag :skip
@tag :integration
test "character movement from A to B adds both systems", %{map: map, character: character} do
# This test verifies:
@@ -224,6 +136,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# 2. Character moves to system B
# 3. update_characters() processes the change
# 4. Both systems are on the map
# Note: The start system is NOT added until the character moves (design decision)
# Setup: Ensure map is started
ensure_map_started(map.id)
@@ -234,33 +147,34 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update - adds Jita
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be on map initially"
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update - should add Amarr
# Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify: Both systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should have been added to map"
# Verify: Both systems should be on map after character moves
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be added as the new location"
end
end
describe "Rapid character movement (Race Condition Tests)" do
@tag :skip
@tag :integration
test "rapid movement A→B→C adds all three systems", %{map: map, character: character} do
# This test verifies the critical race condition fix:
# When a character moves rapidly through multiple systems,
# all systems should be added to the map, not just the start and end.
# Note: Start system is NOT added until character moves (design decision)
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
@@ -268,32 +182,37 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Rapid jump to Amarr (intermediate system)
set_character_location(character.id, @system_amarr)
# Before update_characters can process, character jumps again to Dodixie
# This simulates the race condition
CharactersImpl.update_characters(map.id) # Should process Jita→Amarr
# Second update - should add both Jita (start) and Amarr (current)
CharactersImpl.update_characters(map.id)
# Character already at Dodixie before second update
# Verify both Jita and Amarr are now on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map after movement"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be on map"
# Rapid jump to Dodixie before next update cycle
set_character_location(character.id, @system_dodixie)
CharactersImpl.update_characters(map.id) # Should process Amarr→Dodixie
# Third update - should add Dodixie
CharactersImpl.update_characters(map.id)
# Verify: All three systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should still be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_dodixie), "Dodixie (end) should be on map"
end
@tag :skip
@tag :integration
test "concurrent location updates don't lose intermediate systems", %{
map: map,
@@ -308,7 +227,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Start at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -336,7 +255,6 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
end
describe "start_solar_system_id persistence" do
@tag :skip
@tag :integration
test "start_solar_system_id persists through multiple updates", %{
map: map,
@@ -353,7 +271,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -363,7 +281,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify start_solar_system_id still exists after first update
{:ok, start_system} =
WandererApp.Cache.lookup(
"map_#{map.id}:character:#{character.id}:start_solar_system_id"
"map:#{map.id}:character:#{character.id}:start_solar_system_id"
)
assert start_system == @system_jita,
@@ -380,7 +298,6 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
assert wait_for_system_on_map(map.id, @system_amarr)
end
@tag :skip
@tag :integration
test "first system addition uses correct logic when start_solar_system_id exists", %{
map: map,
@@ -388,6 +305,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
} do
# This test verifies that the first system addition logic
# works correctly with start_solar_system_id
# Design: Start system is NOT added until character moves
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
@@ -397,114 +315,265 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# No old location in map cache (first time tracking)
# This triggers the special first-system-addition logic
# First update - character still at start position
CharactersImpl.update_characters(map.id)
# Verify Jita is added
# Verify Jita is NOT added yet (design: start position not added until movement)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify both systems are added after movement
assert wait_for_system_on_map(map.id, @system_jita),
"First system should be added when character starts tracking"
"Jita should be added after character moves away"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr should be added as the new location"
end
end
describe "Database failure handling" do
@tag :integration
test "database failure during system creation is logged and retried", %{
map: map,
character: character
} do
# This test verifies that database failures don't silently succeed
# and are properly retried
test "system addition failures emit telemetry events", %{map: map, character: character} do
# This test verifies that database failures emit proper telemetry events
# Current implementation logs errors and emits telemetry for failures
# (Retry logic not yet implemented)
# NOTE: This test would need to mock the database to simulate failures
# For now, we document the expected behavior
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Expected behavior:
# 1. maybe_add_system encounters DB error
# 2. Error is logged with context
# 3. Operation is retried (3 attempts with backoff)
# 4. If all retries fail, error tuple is returned (not :ok)
# 5. Telemetry event is emitted for the failure
test_pid = self()
:ok
# Attach handler for system addition error events
:telemetry.attach(
"test-system-addition-error",
[:wanderer_app, :map, :system_addition, :error],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
# Set character at Jita and set start location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Trigger update which may encounter database issues
# In production, database failures would emit telemetry
CharactersImpl.update_characters(map.id)
# Note: In a real database failure scenario, we would receive the telemetry event
# For this test, we verify the mechanism works by checking if the map was started correctly
# and that character updates can complete without crashing
# Verify update_characters completed (returned :ok without crashing)
assert :ok == CharactersImpl.update_characters(map.id)
:telemetry.detach("test-system-addition-error")
end
@tag :integration
test "transient database errors succeed on retry", %{map: map, character: character} do
# This test verifies retry logic for transient failures
# Expected behavior:
# 1. First attempt fails with transient error (timeout, connection, etc.)
# 2. Retry succeeds
# 3. System is added successfully
# 4. Telemetry emitted for both failure and success
:ok
end
@tag :integration
test "permanent database errors don't break update_characters for other characters", %{
test "character update errors are logged but don't crash update_characters", %{
map: map,
character: character
} do
# This test verifies that a failure for one character
# doesn't prevent processing other characters
# This test verifies that errors in character processing are caught
# and logged without crashing the entire update_characters cycle
# Expected behavior:
# 1. Multiple characters being tracked
# 2. One character's update fails permanently
# 3. Other characters' updates succeed
# 4. Error is logged with character context
# 5. update_characters completes for all characters
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
:ok
# Set up character location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Run update_characters - should complete even if individual character updates fail
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Verify the function is resilient and can be called multiple times
result = CharactersImpl.update_characters(map.id)
assert result == :ok
end
@tag :integration
test "errors processing one character don't affect other characters", %{map: map} do
# This test verifies that update_characters processes characters independently
# using Task.async_stream, so one failure doesn't block others
ensure_map_started(map.id)
# Create a second character
user2 = create_user(%{name: "Test User 2", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
character2 = create_character(%{
eve_id: "#{@test_character_eve_id + 1}",
name: "Test Character 2",
user_id: user2.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add both characters to map presence
add_character_to_map_presence(map.id, character2.id)
# Set locations for both characters
set_character_location(character2.id, @system_amarr)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character2.id}:start_solar_system_id",
@system_amarr
)
# Run update_characters - should process both characters independently
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Clean up character 2 caches
cleanup_character_caches(map.id, character2.id)
end
end
describe "Task timeout handling" do
@tag :integration
@tag :slow
test "character update timeout doesn't lose state permanently", %{
map: map,
character: character
} do
# This test verifies that timeouts during update_characters
# don't cause permanent state loss
test "update_characters is resilient to processing delays", %{map: map, character: character} do
# This test verifies that update_characters handles task processing
# without crashing, even when individual character updates might be slow
# (Current implementation: 15-second timeout per task with :kill_task)
# Note: Recovery ETS table not yet implemented
# Expected behavior:
# 1. Character update takes > 15 seconds (simulated slow DB)
# 2. Task times out and is killed
# 3. State is preserved in recovery ETS table
# 4. Next update_characters cycle recovers and processes the update
# 5. System is eventually added to map
# 6. Telemetry emitted for timeout and recovery
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
:ok
# Set up character with location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Run multiple update cycles to verify stability
# If there were timeout/recovery issues, this would fail
for _i <- 1..3 do
result = CharactersImpl.update_characters(map.id)
assert result == :ok
Process.sleep(100)
end
# Verify the map server is still functional
systems = get_map_systems(map.id)
assert is_list(systems)
end
@tag :integration
test "multiple concurrent timeouts don't corrupt cache", %{map: map, character: character} do
# This test verifies that multiple simultaneous timeouts
# don't cause cache corruption
test "concurrent character updates don't cause crashes", %{map: map} do
# This test verifies that processing multiple characters concurrently
# (using Task.async_stream) doesn't cause crashes or corruption
# Even if some tasks might timeout or fail
# Expected behavior:
# 1. Multiple characters timing out simultaneously
# 2. Each timeout is handled independently
# 3. No cache corruption or race conditions
# 4. All characters eventually recover
# 5. Telemetry tracks recovery health
ensure_map_started(map.id)
:ok
# Create multiple characters for concurrent processing
characters = for i <- 1..5 do
user = create_user(%{
name: "Test User #{i}",
hash: "test_hash_#{:rand.uniform(1_000_000)}"
})
character = create_character(%{
eve_id: "#{@test_character_eve_id + i}",
name: "Test Character #{i}",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add character to presence and set location
add_character_to_map_presence(map.id, character.id)
solar_system_id = Enum.at([@system_jita, @system_amarr, @system_dodixie, @system_rens], rem(i, 4))
set_character_location(character.id, solar_system_id)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
solar_system_id
)
character
end
# Run update_characters - should handle all characters concurrently
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Run again to verify stability
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Clean up character caches
Enum.each(characters, fn char ->
cleanup_character_caches(map.id, char.id)
end)
end
@tag :integration
test "update_characters emits telemetry for error cases", %{map: map, character: character} do
# This test verifies that errors during update_characters
# emit proper telemetry events for monitoring
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
test_pid = self()
# Attach handlers for update_characters telemetry
:telemetry.attach_many(
"test-update-characters-telemetry",
[
[:wanderer_app, :map, :update_characters, :start],
[:wanderer_app, :map, :update_characters, :complete],
[:wanderer_app, :map, :update_characters, :error]
],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
# Set up character location
set_character_location(character.id, @system_jita)
# Trigger update_characters
CharactersImpl.update_characters(map.id)
# Should receive start and complete events (or error event if something failed)
assert_receive {:telemetry_event, [:wanderer_app, :map, :update_characters, :start], _, _}, 1000
# Should receive either complete or error event
receive do
{:telemetry_event, [:wanderer_app, :map, :update_characters, :complete], _, _} -> :ok
{:telemetry_event, [:wanderer_app, :map, :update_characters, :error], _, _} -> :ok
after
1000 -> flunk("Expected to receive complete or error telemetry event")
end
:telemetry.detach("test-update-characters-telemetry")
end
end
describe "Cache consistency" do
@tag :skip
@tag :integration
test "character cache and map cache stay in sync", %{map: map, character: character} do
# This test verifies that the three character location caches
@@ -521,7 +590,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set location in character cache
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -529,7 +598,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify map cache was updated
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert map_cached_location == @system_jita,
"Map-specific cache should match character cache"
@@ -541,7 +610,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify both caches updated
{:ok, character_data} = Cachex.get(:character_cache, character.id)
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert character_data.solar_system_id == @system_amarr
assert map_cached_location == @system_amarr,
@@ -550,7 +619,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
end
describe "Telemetry and observability" do
test "telemetry events are emitted for location updates", %{character: character} do
test "telemetry events are emitted for location updates", %{character: character, map: map} do
# This test verifies that telemetry is emitted for tracking debugging
test_pid = self()
@@ -574,7 +643,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
:telemetry.execute(
[:wanderer_app, :character, :location_update, :start],
%{system_time: System.system_time()},
%{character_id: character.id, map_id: @test_map_id}
%{character_id: character.id, map_id: map.id}
)
:telemetry.execute(
@@ -582,7 +651,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
%{duration: 100, system_time: System.system_time()},
%{
character_id: character.id,
map_id: @test_map_id,
map_id: map.id,
from_system: @system_jita,
to_system: @system_amarr
}

View File

@@ -117,16 +117,18 @@ defmodule WandererApp.DataCase do
:ok
end
end)
end
@doc """
Grants database access to a process with comprehensive monitoring.
# Grant database access to MapPoolSupervisor and all its dynamically started children
case Process.whereis(WandererApp.Map.MapPoolSupervisor) do
pid when is_pid(pid) ->
# Grant access to the supervisor and its entire supervision tree
# This ensures dynamically started map servers get database access
owner_pid = Process.get(:sandbox_owner_pid) || self()
WandererApp.Test.DatabaseAccessManager.grant_supervision_tree_access(pid, owner_pid)
This function provides enhanced database access granting with monitoring
for child processes and automatic access granting.
"""
def allow_database_access(pid, owner_pid \\ self()) do
WandererApp.Test.DatabaseAccessManager.grant_database_access(pid, owner_pid)
_ ->
:ok
end
end
@doc """

View File

@@ -22,6 +22,9 @@ defmodule WandererApp.Test.IntegrationConfig do
# Ensure PubSub server is started for integration tests
ensure_pubsub_server()
# Ensure map supervisors are started for map-related integration tests
ensure_map_supervisors_started()
:ok
end
@@ -57,6 +60,42 @@ defmodule WandererApp.Test.IntegrationConfig do
end
end
@doc """
Ensures map supervisors are started for integration tests.
This starts both MapPoolSupervisor and Map.Manager which are
required for character location tracking and map management tests.
IMPORTANT: MapPoolSupervisor must be started BEFORE Map.Manager
because Map.Manager depends on the registries created by MapPoolSupervisor.
"""
def ensure_map_supervisors_started do
# Start MapPoolSupervisor FIRST if not running
# This supervisor creates the required registries (:map_pool_registry, :unique_map_pool_registry)
# and starts MapPoolDynamicSupervisor
case Process.whereis(WandererApp.Map.MapPoolSupervisor) do
nil ->
{:ok, _} = WandererApp.Map.MapPoolSupervisor.start_link([])
_ ->
:ok
end
# Give the supervisor a moment to fully initialize its children
Process.sleep(100)
# Start Map.Manager AFTER MapPoolSupervisor
case GenServer.whereis(WandererApp.Map.Manager) do
nil ->
{:ok, _} = WandererApp.Map.Manager.start_link([])
_ ->
:ok
end
:ok
end
@doc """
Cleans up integration test environment.
@@ -74,6 +113,8 @@ defmodule WandererApp.Test.IntegrationConfig do
end
# Note: PubSub cleanup is handled by Phoenix during test shutdown
# Note: Map supervisors are not cleaned up here as they may be shared
# across tests and should persist for the test session
:ok
end

View File

@@ -1,8 +1,13 @@
defmodule WandererApp.MapTestHelpers do
@moduledoc """
Shared helper functions for map-related tests.
Shared helper functions for map-related integration tests.
This module provides common functionality for testing map servers,
character location tracking, and system management.
"""
import Mox
@doc """
Helper function to expect a map server error response.
This function is used across multiple test files to handle
@@ -17,4 +22,411 @@ defmodule WandererApp.MapTestHelpers do
:ok
end
end
@doc """
Ensures the map is started for the given map ID.
Uses async Map.Manager.start_map and waits for completion.
## Parameters
- map_id: The ID of the map to start
## Examples
iex> ensure_map_started(map.id)
:ok
"""
def ensure_map_started(map_id) do
# Queue the map for starting (async)
:ok = WandererApp.Map.Manager.start_map(map_id)
# Wait for the map to actually start
wait_for_map_started(map_id)
end
@doc """
Waits for a map to finish starting by polling the cache.
## Parameters
- map_id: The ID of the map to wait for
- timeout: Maximum time to wait in milliseconds (default: 10000)
## Examples
iex> wait_for_map_started(map.id, 5000)
:ok
"""
def wait_for_map_started(map_id, timeout \\ 10_000) do
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
# Check both the map_started flag and the started_maps list
map_started_flag =
case WandererApp.Cache.lookup("map_#{map_id}:started") do
{:ok, true} -> true
_ -> false
end
in_started_maps_list =
case WandererApp.Cache.lookup("started_maps", []) do
{:ok, started_maps} when is_list(started_maps) ->
Enum.member?(started_maps, map_id)
_ ->
false
end
cond do
# Map is fully started
map_started_flag and in_started_maps_list ->
{:ok, :started}
# Map is partially started (in one but not both) - keep waiting
map_started_flag or in_started_maps_list ->
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(100)
:continue
else
{:error, :timeout}
end
# Map not started yet
true ->
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(100)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, :started} ->
# Give it a bit more time to fully initialize all subsystems
Process.sleep(200)
:ok
{:error, :timeout} ->
raise "Timeout waiting for map #{map_id} to start. Check Map.Manager is running."
end
end
@doc """
Sets up DDRT (R-tree spatial index) mock stubs.
This is required for system positioning on the map.
We stub all R-tree operations to allow systems to be placed anywhere.
## Examples
iex> setup_ddrt_mocks()
:ok
"""
def setup_ddrt_mocks do
Test.DDRTMock
|> stub(:init_tree, fn _name, _opts -> :ok end)
|> stub(:insert, fn _data, _tree_name -> {:ok, %{}} end)
|> stub(:update, fn _id, _data, _tree_name -> {:ok, %{}} end)
|> stub(:delete, fn _ids, _tree_name -> {:ok, %{}} end)
# query returns empty list to indicate no spatial conflicts (position is available)
|> stub(:query, fn _bbox, _tree_name -> {:ok, []} end)
:ok
end
@doc """
Populates the system static info cache with data for common test systems.
This is required for SystemsImpl.maybe_add_system to work properly,
as it needs to fetch system names and other metadata.
## Parameters
- systems: Map of solar_system_id => system_info (optional, uses defaults if not provided)
## Examples
iex> setup_system_static_info_cache()
:ok
"""
def setup_system_static_info_cache(systems \\ nil) do
test_systems = systems || default_test_systems()
Enum.each(test_systems, fn {solar_system_id, system_info} ->
Cachex.put(:system_static_info_cache, solar_system_id, system_info)
end)
:ok
end
@doc """
Returns default test system configurations for common EVE systems.
## Examples
iex> default_test_systems()
%{30_000_142 => %{...}}
"""
def default_test_systems do
%{
# Jita
30_000_142 => %{
solar_system_id: 30_000_142,
region_id: 10_000_002,
constellation_id: 20_000_020,
solar_system_name: "Jita",
solar_system_name_lc: "jita",
constellation_name: "Kimotoro",
region_name: "The Forge",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Amarr
30_002_187 => %{
solar_system_id: 30_002_187,
region_id: 10_000_043,
constellation_id: 20_000_304,
solar_system_name: "Amarr",
solar_system_name_lc: "amarr",
constellation_name: "Throne Worlds",
region_name: "Domain",
system_class: 0,
security: "1.0",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Dodixie
30_002_659 => %{
solar_system_id: 30_002_659,
region_id: 10_000_032,
constellation_id: 20_000_413,
solar_system_name: "Dodixie",
solar_system_name_lc: "dodixie",
constellation_name: "Sinq Laison",
region_name: "Sinq Laison",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Rens
30_002_510 => %{
solar_system_id: 30_002_510,
region_id: 10_000_030,
constellation_id: 20_000_387,
solar_system_name: "Rens",
solar_system_name_lc: "rens",
constellation_name: "Frarn",
region_name: "Heimatar",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}
}
end
@doc """
Helper to simulate character location update in cache.
This mimics what the Character.Tracker does when it polls ESI.
## Parameters
- character_id: The character ID to update
- solar_system_id: The solar system ID where the character is located
- opts: Optional parameters (structure_id, station_id, ship)
## Examples
iex> set_character_location(character.id, 30_000_142, ship: 670)
:ok
"""
def set_character_location(character_id, solar_system_id, opts \\ []) do
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship = opts[:ship] || 670 # Capsule
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)
# Update character cache (mimics Character.update_character/2)
character_data =
Map.merge(existing_character, %{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship: ship,
updated_at: DateTime.utc_now()
})
Cachex.put(:character_cache, character_id, character_data)
end
@doc """
Helper to add character to map's presence list.
This mimics what PresenceGracePeriodManager does.
## Parameters
- map_id: The map ID
- character_id: The character ID to add
## Examples
iex> add_character_to_map_presence(map.id, character.id)
:ok
"""
def add_character_to_map_presence(map_id, character_id) do
{:ok, current_chars} = WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
updated_chars = Enum.uniq([character_id | current_chars])
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", updated_chars)
end
@doc """
Helper to get all systems currently on the map.
Uses :map_cache instead of :map_state_cache because add_system/2 updates :map_cache.
## Parameters
- map_id: The map ID
## Returns
- List of systems on the map
## Examples
iex> get_map_systems(map.id)
[%{solar_system_id: 30_000_142, ...}, ...]
"""
def get_map_systems(map_id) do
case WandererApp.Map.get_map(map_id) do
{:ok, %{systems: systems}} when is_map(systems) ->
Map.values(systems)
{:ok, _} ->
[]
{:error, _} ->
[]
end
end
@doc """
Checks if a specific system is on the map.
## Parameters
- map_id: The map ID
- solar_system_id: The solar system ID to check
## Returns
- true if the system is on the map, false otherwise
## Examples
iex> system_on_map?(map.id, 30_000_142)
true
"""
def system_on_map?(map_id, solar_system_id) do
systems = get_map_systems(map_id)
Enum.any?(systems, fn sys -> sys.solar_system_id == solar_system_id end)
end
@doc """
Waits for a system to appear on the map (for async operations).
## Parameters
- map_id: The map ID
- solar_system_id: The solar system ID to wait for
- timeout: Maximum time to wait in milliseconds (default: 2000)
## Returns
- true if the system appears on the map, false if timeout
## Examples
iex> wait_for_system_on_map(map.id, 30_000_142, 5000)
true
"""
def wait_for_system_on_map(map_id, solar_system_id, timeout \\ 2000) do
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
if system_on_map?(map_id, solar_system_id) do
{:ok, true}
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(50)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, true} -> true
{:error, :timeout} -> false
end
end
@doc """
Cleans up character location caches for a specific character and map.
## Parameters
- map_id: The map ID
- character_id: The character ID
## Examples
iex> cleanup_character_caches(map.id, character.id)
:ok
"""
def cleanup_character_caches(map_id, character_id) do
# Clean up character location caches
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:structure_id")
# Clean up character cache
if Cachex.exists?(:character_cache, character_id) do
Cachex.del(:character_cache, character_id)
end
# Clean up character state cache
if Cachex.exists?(:character_state_cache, character_id) do
Cachex.del(:character_state_cache, character_id)
end
:ok
end
@doc """
Cleans up test data for a map.
## Parameters
- map_id: The map ID
## Examples
iex> cleanup_test_data(map.id)
:ok
"""
def cleanup_test_data(map_id) do
# Clean up map-level presence tracking
WandererApp.Cache.delete("map_#{map_id}:presence_character_ids")
:ok
end
end

View File

@@ -176,103 +176,19 @@ defmodule WandererApp.TestHelpers do
@doc """
Ensures a map server is started for testing.
This function has been simplified to use the standard map startup flow.
For integration tests, use WandererApp.MapTestHelpers.ensure_map_started/1 instead.
"""
def ensure_map_server_started(map_id) do
case WandererApp.Map.Server.map_pid(map_id) do
pid when is_pid(pid) ->
# Make sure existing server has database access
WandererApp.DataCase.allow_database_access(pid)
# Also allow database access for any spawned processes
allow_map_server_children_database_access(pid)
# Ensure global Mox mode is maintained
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
:ok
# Ensure global Mox mode is maintained
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
nil ->
# Ensure global Mox mode before starting map server
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
# Start the map server directly for tests
{:ok, pid} = start_map_server_directly(map_id)
# Grant database access to the new map server process
WandererApp.DataCase.allow_database_access(pid)
# Allow database access for any spawned processes
allow_map_server_children_database_access(pid)
:ok
end
end
# Use the standard map startup flow through Map.Manager
:ok = WandererApp.Map.Manager.start_map(map_id)
defp start_map_server_directly(map_id) do
# Use the same approach as MapManager.start_map_server/1
case DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {WandererApp.Map.DynamicSupervisors, self()}},
{WandererApp.Map.ServerSupervisor, map_id: map_id}
) do
{:ok, pid} ->
# Allow database access for the supervisor and its children
WandererApp.DataCase.allow_genserver_database_access(pid)
# Wait a bit for the map to fully initialize
:timer.sleep(500)
# Allow Mox access for the supervisor process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(pid)
# Also get the actual map server pid and allow access
case WandererApp.Map.Server.map_pid(map_id) do
server_pid when is_pid(server_pid) ->
WandererApp.DataCase.allow_genserver_database_access(server_pid)
# Allow Mox access for the map server process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(server_pid)
_ ->
:ok
end
{:ok, pid}
{:error, {:already_started, pid}} ->
WandererApp.DataCase.allow_database_access(pid)
{:ok, pid}
{:error, :max_children} ->
# If we hit max children, wait a bit and retry
:timer.sleep(100)
start_map_server_directly(map_id)
error ->
error
end
end
defp allow_map_server_children_database_access(map_server_pid) do
# Allow database access for all children processes
# This is important for MapEventRelay and other spawned processes
# Wait a bit for children to spawn
:timer.sleep(100)
# Get all linked processes
case Process.info(map_server_pid, :links) do
{:links, linked_pids} ->
Enum.each(linked_pids, fn linked_pid ->
if is_pid(linked_pid) and Process.alive?(linked_pid) do
WandererApp.DataCase.allow_database_access(linked_pid)
# Also check for their children
case Process.info(linked_pid, :links) do
{:links, sub_links} ->
Enum.each(sub_links, fn sub_pid ->
if is_pid(sub_pid) and Process.alive?(sub_pid) and sub_pid != map_server_pid do
WandererApp.DataCase.allow_database_access(sub_pid)
end
end)
_ ->
:ok
end
end
end)
_ ->
:ok
end
:ok
end
end

View File

@@ -38,51 +38,52 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "insert/2" do
test "inserts single leaf", %{tree_name: name} do
leaf = {30000142, [{100, 230}, {50, 84}]}
leaf = {30_000_142, [{100, 230}, {50, 84}]}
assert {:ok, %{}} = CacheRTree.insert(leaf, name)
# Verify insertion
{:ok, ids} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "inserts multiple leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
assert {:ok, %{}} = CacheRTree.insert(leaves, name)
# Verify all insertions
{:ok, ids1} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{250, 380}, {100, 134}], name)
assert 30000143 in ids2
assert 30_000_143 in ids2
{:ok, ids3} = CacheRTree.query([{400, 530}, {50, 84}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
test "handles duplicate ID by overwriting", %{tree_name: name} do
# Insert first time
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Insert same ID with different bounding box
CacheRTree.insert({30000142, [{200, 330}, {100, 134}]}, name)
CacheRTree.insert({30_000_142, [{200, 330}, {100, 134}]}, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query([{200, 330}, {100, 134}], name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
# Should NOT find in old location
{:ok, ids_old} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 not in ids_old
assert 30_000_142 not in ids_old
end
test "handles integer IDs", %{tree_name: name} do
leaf = {123456, [{0, 130}, {0, 34}]}
leaf = {123_456, [{0, 130}, {0, 34}]}
assert {:ok, %{}} = CacheRTree.insert(leaf, name)
end
@@ -97,8 +98,8 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "delete/2" do
test "deletes single leaf", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
assert {:ok, %{}} = CacheRTree.delete([30000142], name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142], name)
# Verify deletion
{:ok, ids} = CacheRTree.query([{100, 230}, {50, 84}], name)
@@ -107,14 +108,15 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "deletes multiple leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
CacheRTree.insert(leaves, name)
# Delete two of them
assert {:ok, %{}} = CacheRTree.delete([30000142, 30000143], name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142, 30_000_143], name)
# Verify deletions
{:ok, ids1} = CacheRTree.query([{100, 230}, {50, 84}], name)
@@ -125,7 +127,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
# Third should still exist
{:ok, ids3} = CacheRTree.query([{400, 530}, {50, 84}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
test "handles non-existent ID gracefully", %{tree_name: name} do
@@ -134,39 +136,39 @@ defmodule WandererApp.Map.CacheRTreeTest do
end
test "handles deleting from empty tree", %{tree_name: name} do
assert {:ok, %{}} = CacheRTree.delete([30000142], name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142], name)
end
end
describe "update/3" do
test "updates leaf with new bounding box", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Update to new position
new_box = [{200, 330}, {100, 134}]
assert {:ok, %{}} = CacheRTree.update(30000142, new_box, name)
assert {:ok, %{}} = CacheRTree.update(30_000_142, new_box, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query(new_box, name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
# Should NOT find in old location
{:ok, ids_old} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 not in ids_old
assert 30_000_142 not in ids_old
end
test "updates leaf with old/new tuple", %{tree_name: name} do
old_box = [{100, 230}, {50, 84}]
new_box = [{200, 330}, {100, 134}]
CacheRTree.insert({30000142, old_box}, name)
CacheRTree.insert({30_000_142, old_box}, name)
# Update with tuple
assert {:ok, %{}} = CacheRTree.update(30000142, {old_box, new_box}, name)
assert {:ok, %{}} = CacheRTree.update(30_000_142, {old_box, new_box}, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query(new_box, name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
end
test "handles updating non-existent leaf", %{tree_name: name} do
@@ -196,23 +198,25 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "finds intersecting leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
CacheRTree.insert(leaves, name)
# Query overlapping with first system
{:ok, ids} = CacheRTree.query([{150, 280}, {60, 94}], name)
assert 30000142 in ids
assert 30_000_142 in ids
assert length(ids) == 1
end
test "excludes non-intersecting leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]}
]
CacheRTree.insert(leaves, name)
# Query that doesn't intersect any leaf
@@ -223,46 +227,48 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "handles overlapping bounding boxes", %{tree_name: name} do
# Insert overlapping systems
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{150, 280}, {60, 94}]} # Overlaps with first
{30_000_142, [{100, 230}, {50, 84}]},
# Overlaps with first
{30_000_143, [{150, 280}, {60, 94}]}
]
CacheRTree.insert(leaves, name)
# Query that overlaps both
{:ok, ids} = CacheRTree.query([{175, 200}, {65, 80}], name)
assert 30000142 in ids
assert 30000143 in ids
assert 30_000_142 in ids
assert 30_000_143 in ids
assert length(ids) == 2
end
test "edge case: exact match", %{tree_name: name} do
box = [{100, 230}, {50, 84}]
CacheRTree.insert({30000142, box}, name)
CacheRTree.insert({30_000_142, box}, name)
{:ok, ids} = CacheRTree.query(box, name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: contained box", %{tree_name: name} do
# Insert larger box
CacheRTree.insert({30000142, [{100, 300}, {50, 150}]}, name)
CacheRTree.insert({30_000_142, [{100, 300}, {50, 150}]}, name)
# Query with smaller box inside
{:ok, ids} = CacheRTree.query([{150, 250}, {75, 100}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: containing box", %{tree_name: name} do
# Insert smaller box
CacheRTree.insert({30000142, [{150, 250}, {75, 100}]}, name)
CacheRTree.insert({30_000_142, [{150, 250}, {75, 100}]}, name)
# Query with larger box that contains it
{:ok, ids} = CacheRTree.query([{100, 300}, {50, 150}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: adjacent boxes don't intersect", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Adjacent box (touching but not overlapping)
{:ok, ids} = CacheRTree.query([{230, 360}, {50, 84}], name)
@@ -271,13 +277,14 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "handles negative coordinates", %{tree_name: name} do
leaves = [
{30000142, [{-200, -70}, {-100, -66}]},
{30000143, [{-50, 80}, {-25, 9}]}
{30_000_142, [{-200, -70}, {-100, -66}]},
{30_000_143, [{-50, 80}, {-25, 9}]}
]
CacheRTree.insert(leaves, name)
{:ok, ids} = CacheRTree.query([{-150, -100}, {-90, -70}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
end
@@ -285,40 +292,40 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "correctly maps leaves to grid cells", %{tree_name: name} do
# System node is 130x34, grid is 150x150
# This should fit in one cell
leaf = {30000142, [{10, 140}, {10, 44}]}
leaf = {30_000_142, [{10, 140}, {10, 44}]}
CacheRTree.insert(leaf, name)
# Query should find it
{:ok, ids} = CacheRTree.query([{10, 140}, {10, 44}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "handles leaves spanning multiple cells", %{tree_name: name} do
# Large box spanning 4 grid cells (150x150 each)
large_box = [{0, 300}, {0, 300}]
CacheRTree.insert({30000142, large_box}, name)
CacheRTree.insert({30_000_142, large_box}, name)
# Should be queryable from any quadrant
{:ok, ids1} = CacheRTree.query([{50, 100}, {50, 100}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{200, 250}, {50, 100}], name)
assert 30000142 in ids2
assert 30_000_142 in ids2
{:ok, ids3} = CacheRTree.query([{50, 100}, {200, 250}], name)
assert 30000142 in ids3
assert 30_000_142 in ids3
{:ok, ids4} = CacheRTree.query([{200, 250}, {200, 250}], name)
assert 30000142 in ids4
assert 30_000_142 in ids4
end
test "maintains grid consistency on delete", %{tree_name: name} do
# Insert leaf spanning multiple cells
large_box = [{0, 300}, {0, 300}]
CacheRTree.insert({30000142, large_box}, name)
CacheRTree.insert({30_000_142, large_box}, name)
# Delete it
CacheRTree.delete([30000142], name)
CacheRTree.delete([30_000_142], name)
# Should not be found in any cell
{:ok, ids1} = CacheRTree.query([{50, 100}, {50, 100}], name)
@@ -331,56 +338,61 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "grid handles boundary conditions", %{tree_name: name} do
# Boxes exactly on grid boundaries
leaves = [
{30000142, [{0, 130}, {0, 34}]}, # Cell (0,0)
{30000143, [{150, 280}, {0, 34}]}, # Cell (1,0)
{30000144, [{0, 130}, {150, 184}]} # Cell (0,1)
# Cell (0,0)
{30_000_142, [{0, 130}, {0, 34}]},
# Cell (1,0)
{30_000_143, [{150, 280}, {0, 34}]},
# Cell (0,1)
{30_000_144, [{0, 130}, {150, 184}]}
]
CacheRTree.insert(leaves, name)
# Each should be queryable
{:ok, ids1} = CacheRTree.query([{0, 130}, {0, 34}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{150, 280}, {0, 34}], name)
assert 30000143 in ids2
assert 30_000_143 in ids2
{:ok, ids3} = CacheRTree.query([{0, 130}, {150, 184}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
end
describe "integration" do
test "realistic map scenario with many systems", %{tree_name: name} do
# Simulate 100 systems in a typical map layout
systems = for i <- 1..100 do
x = rem(i, 10) * 200
y = div(i, 10) * 100
{30000000 + i, [{x, x + 130}, {y, y + 34}]}
end
systems =
for i <- 1..100 do
x = rem(i, 10) * 200
y = div(i, 10) * 100
{30_000_000 + i, [{x, x + 130}, {y, y + 34}]}
end
# Insert all systems
assert {:ok, %{}} = CacheRTree.insert(systems, name)
# Query for a specific position
{:ok, ids} = CacheRTree.query([{200, 330}, {100, 134}], name)
assert 30000012 in ids
assert 30_000_012 in ids
# Delete some systems
to_delete = Enum.map(1..10, & &1 + 30000000)
to_delete = Enum.map(1..10, &(&1 + 30_000_000))
assert {:ok, %{}} = CacheRTree.delete(to_delete, name)
# Update some systems
assert {:ok, %{}} = CacheRTree.update(30000050, [{1000, 1130}, {500, 534}], name)
assert {:ok, %{}} = CacheRTree.update(30_000_050, [{1000, 1130}, {500, 534}], name)
# Verify the update
{:ok, ids_updated} = CacheRTree.query([{1000, 1130}, {500, 534}], name)
assert 30000050 in ids_updated
assert 30_000_050 in ids_updated
end
test "handles rapid insert/delete cycles", %{tree_name: name} do
# Simulate dynamic map updates
for i <- 1..50 do
system_id = 30000000 + i
system_id = 30_000_000 + i
box = [{i * 10, i * 10 + 130}, {i * 5, i * 5 + 34}]
# Insert
@@ -402,7 +414,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "stress test: position availability checking", %{tree_name: name} do
# Insert systems in a grid pattern
for x <- 0..9, y <- 0..9 do
system_id = x * 10 + y + 30000000
system_id = x * 10 + y + 30_000_000
box = [{x * 200, x * 200 + 130}, {y * 100, y * 100 + 34}]
CacheRTree.insert({system_id, box}, name)
end
@@ -421,7 +433,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "clear_tree/1" do
test "removes all tree data from cache", %{tree_name: name} do
# Insert some data
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Clear the tree
assert :ok = CacheRTree.clear_tree(name)

View File

@@ -513,14 +513,16 @@ defmodule WandererApp.Map.MapPoolCrashRecoveryTest do
map_ids = Enum.to_list(1..20)
# Measure save time
{save_time_us, :ok} = :timer.tc(fn ->
MapPoolState.save_pool_state(uuid, map_ids)
end)
{save_time_us, :ok} =
:timer.tc(fn ->
MapPoolState.save_pool_state(uuid, map_ids)
end)
# Measure retrieval time
{get_time_us, {:ok, _}} = :timer.tc(fn ->
MapPoolState.get_pool_state(uuid)
end)
{get_time_us, {:ok, _}} =
:timer.tc(fn ->
MapPoolState.get_pool_state(uuid)
end)
# Both operations should be very fast (< 1ms)
assert save_time_us < 1000, "Save took #{save_time_us}µs, expected < 1000µs"
@@ -543,9 +545,10 @@ defmodule WandererApp.Map.MapPoolCrashRecoveryTest do
end)
# Measure cleanup time
{cleanup_time_us, {:ok, deleted_count}} = :timer.tc(fn ->
MapPoolState.cleanup_stale_entries()
end)
{cleanup_time_us, {:ok, deleted_count}} =
:timer.tc(fn ->
MapPoolState.cleanup_stale_entries()
end)
# Should have deleted at least 100 entries
assert deleted_count >= 100

View File

@@ -50,7 +50,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "cache lookup with registry fallback" do
test "stop_map handles cache miss by scanning registry", %{registries_running: registries_running?} do
test "stop_map handles cache miss by scanning registry", %{
registries_running: registries_running?
} do
if registries_running? do
# Setup: Create a map_id that's not in cache but will be found in registry scan
map_id = "test_map_#{:rand.uniform(1_000_000)}"
@@ -66,7 +68,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "stop_map handles non-existent pool_uuid in registry", %{registries_running: registries_running?} do
test "stop_map handles non-existent pool_uuid in registry", %{
registries_running: registries_running?
} do
if registries_running? do
map_id = "test_map_#{:rand.uniform(1_000_000)}"
fake_uuid = "fake_uuid_#{:rand.uniform(1_000_000)}"
@@ -81,7 +85,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "stop_map updates cache when found via registry scan", %{registries_running: registries_running?} do
test "stop_map updates cache when found via registry scan", %{
registries_running: registries_running?
} do
if registries_running? do
# This test would require a running pool with registered maps
# For now, we verify the fallback logic doesn't crash
@@ -115,7 +121,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - zombie map detection and cleanup" do
test "reconciler detects zombie maps in started_maps cache", %{reconciler_running: reconciler_running?} do
test "reconciler detects zombie maps in started_maps cache", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# Setup: Add maps to started_maps that aren't in any registry
zombie_map_id = "zombie_map_#{:rand.uniform(1_000_000)}"
@@ -189,7 +197,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - cache inconsistency detection and fix" do
test "reconciler detects map with missing cache entry", %{reconciler_running: reconciler_running?} do
test "reconciler detects map with missing cache entry", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# This test verifies the reconciler can detect when a map
# is in the registry but has no cache entry
@@ -209,7 +219,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "reconciler detects cache pointing to non-existent pool", %{reconciler_running: reconciler_running?} do
test "reconciler detects cache pointing to non-existent pool", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
map_id = "test_map_#{:rand.uniform(1_000_000)}"
fake_uuid = "fake_uuid_#{:rand.uniform(1_000_000)}"
@@ -267,7 +279,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - manual trigger" do
test "trigger_reconciliation runs reconciliation immediately", %{reconciler_running: reconciler_running?} do
test "trigger_reconciliation runs reconciliation immediately", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
zombie_map_id = "zombie_map_#{:rand.uniform(1_000_000)}"
@@ -307,7 +321,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "reconciler handles empty registries gracefully", %{reconciler_running: reconciler_running?} do
test "reconciler handles empty registries gracefully", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# Clear everything
cleanup_test_data()

View File

@@ -0,0 +1,287 @@
defmodule WandererApp.Map.SlugRecoveryTest do
use WandererApp.DataCase, async: false
alias WandererApp.Map.SlugRecovery
alias WandererApp.Api.Map
alias WandererApp.Repo
describe "recover_duplicate_slug/1" do
test "returns ok when no duplicates exist" do
# Create a single map
user = create_test_user()
{:ok, _map} = create_map(user, "unique-map")
# Should return ok with no fixes needed
assert {:ok, result} = SlugRecovery.recover_duplicate_slug("unique-map")
assert result.fixed_count == 0
assert result.kept_map_id == nil
end
test "returns ok when slug doesn't exist" do
assert {:ok, result} = SlugRecovery.recover_duplicate_slug("nonexistent-slug")
assert result.fixed_count == 0
end
test "fixes duplicate slugs by renaming newer maps" do
user = create_test_user()
# Temporarily drop the unique index to allow duplicate insertion for testing
drop_unique_index()
# Create duplicates by directly inserting into database (bypassing Ash validations)
map1_id = insert_map_directly("duplicate-slug", "Map 1", user.id, false)
map2_id = insert_map_directly("duplicate-slug", "Map 2", user.id, false)
map3_id = insert_map_directly("duplicate-slug", "Map 3", user.id, false)
# Recreate the index after inserting test data (recovery will handle the duplicates)
# Note: This will fail due to duplicates, which is expected
try do
create_unique_index()
rescue
_ -> :ok
end
# Verify duplicates exist
assert count_maps_with_slug("duplicate-slug") == 3
# Run recovery
assert {:ok, result} = SlugRecovery.recover_duplicate_slug("duplicate-slug")
assert result.fixed_count == 2
assert result.kept_map_id == map1_id
# Verify only one map has original slug (the oldest)
assert count_maps_with_slug("duplicate-slug") == 1
# Verify the kept map
{:ok, kept_map} = Repo.query("SELECT id FROM maps_v1 WHERE slug = $1", ["duplicate-slug"])
[[kept_id_binary]] = kept_map.rows
assert Ecto.UUID.load!(kept_id_binary) == map1_id
# Verify the other maps were renamed with numeric suffixes
{:ok, map2_result} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map2_id)])
[[map2_slug]] = map2_result.rows
assert map2_slug == "duplicate-slug-2"
{:ok, map3_result} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
[[map3_slug]] = map3_result.rows
assert map3_slug == "duplicate-slug-3"
# Recreate index after test
create_unique_index()
end
test "handles deleted maps with duplicate slugs" do
user = create_test_user()
# Temporarily drop the unique index
drop_unique_index()
# Create duplicates including deleted ones
map1_id = insert_map_directly("deleted-dup", "Map 1", user.id, false)
map2_id = insert_map_directly("deleted-dup", "Map 2", user.id, true)
map3_id = insert_map_directly("deleted-dup", "Map 3", user.id, false)
assert count_maps_with_slug("deleted-dup") == 3
# Run recovery - should handle all maps regardless of deleted status
assert {:ok, result} = SlugRecovery.recover_duplicate_slug("deleted-dup")
assert result.fixed_count == 2
# Only one map should have the original slug
assert count_maps_with_slug("deleted-dup") == 1
# The oldest (map1) should have kept the slug
{:ok, kept_map} = Repo.query("SELECT id FROM maps_v1 WHERE slug = $1", ["deleted-dup"])
[[kept_id_binary]] = kept_map.rows
assert Ecto.UUID.load!(kept_id_binary) == map1_id
# Recreate index after test
create_unique_index()
end
test "generates unique slugs when numeric suffixes already exist" do
user = create_test_user()
# Temporarily drop the unique index
drop_unique_index()
# Create maps with conflicting slugs including numeric suffixes
map1_id = insert_map_directly("test", "Map 1", user.id, false)
_map2_id = insert_map_directly("test-2", "Map 2", user.id, false)
map3_id = insert_map_directly("test", "Map 3", user.id, false)
# Run recovery on "test"
assert {:ok, result} = SlugRecovery.recover_duplicate_slug("test")
assert result.fixed_count == 1
# Map 3 should get "test-3" since "test-2" is already taken
{:ok, map3} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
assert map3.rows == [["test-3"]]
# Recreate index after test
create_unique_index()
end
end
describe "recover_all_duplicates/0" do
test "finds and fixes all duplicate slugs in database" do
user = create_test_user()
# Temporarily drop the unique index
drop_unique_index()
# Create multiple sets of duplicates
insert_map_directly("dup1", "Map 1", user.id, false)
insert_map_directly("dup1", "Map 2", user.id, false)
insert_map_directly("dup2", "Map 3", user.id, false)
insert_map_directly("dup2", "Map 4", user.id, false)
insert_map_directly("dup2", "Map 5", user.id, false)
# Create a unique one (should be ignored)
insert_map_directly("unique", "Unique", user.id, false)
# Run full recovery
assert {:ok, stats} = SlugRecovery.recover_all_duplicates()
assert stats.total_slugs_fixed == 2
assert stats.total_maps_renamed == 3
# Verify all duplicates are fixed
{:ok, result} =
Repo.query("SELECT slug, COUNT(*) FROM maps_v1 GROUP BY slug HAVING COUNT(*) > 1")
assert result.rows == []
# Recreate index after test
create_unique_index()
end
test "returns ok when no duplicates exist" do
user = create_test_user()
# Create only unique maps
insert_map_directly("unique1", "Map 1", user.id, false)
insert_map_directly("unique2", "Map 2", user.id, false)
assert {:ok, stats} = SlugRecovery.recover_all_duplicates()
assert stats.total_slugs_fixed == 0
assert stats.total_maps_renamed == 0
end
end
describe "verify_unique_index/0" do
test "returns :exists when index is present" do
# The index should exist from migrations
assert {:ok, :exists} = SlugRecovery.verify_unique_index()
end
end
describe "integration with MapRepo.get_map_by_slug_safely/1" do
test "automatically recovers and retries when duplicates are found" do
user = create_test_user()
# Temporarily drop the unique index
drop_unique_index()
# Create duplicates
_map1_id = insert_map_directly("auto-recover", "Map 1", user.id, false)
_map2_id = insert_map_directly("auto-recover", "Map 2", user.id, false)
# Verify duplicates exist
assert count_maps_with_slug("auto-recover") == 2
# Call get_map_by_slug_safely - should automatically recover and succeed
assert {:ok, map} = WandererApp.MapRepo.get_map_by_slug_safely("auto-recover")
assert map.slug == "auto-recover"
# Verify duplicates were fixed
assert count_maps_with_slug("auto-recover") == 1
# Recreate index after test
create_unique_index()
end
test "returns error after failed recovery attempt" do
# This test simulates a scenario where recovery fails
# In practice, this would be rare, but we should handle it gracefully
# Try to get a non-existent slug
assert {:error, :not_found} = WandererApp.MapRepo.get_map_by_slug_safely("nonexistent")
end
end
# Helper functions
defp create_test_user do
# Generate a unique EVE ID (9 digits, as a string)
eve_id = "10#{:rand.uniform(9_999_999) |> Integer.to_string() |> String.pad_leading(7, "0")}"
{:ok, user} =
WandererApp.Api.Character.create(%{
eve_id: eve_id,
name: "Test User #{:rand.uniform(10000)}"
})
user
end
defp create_map(user, slug) do
Map.new(%{
name: "Test Map",
slug: slug,
owner_id: user.id,
scope: :wormholes
})
end
defp insert_map_directly(slug, name, owner_id, deleted) do
# Insert directly into database to bypass Ash validations
# This simulates the duplicate slug scenario that can happen in production
# Convert UUID string to binary format for PostgreSQL
owner_id_binary = Ecto.UUID.dump!(owner_id)
query = """
INSERT INTO maps_v1 (id, slug, name, owner_id, deleted, scope, inserted_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'wormholes', NOW(), NOW())
RETURNING id
"""
{:ok, result} = Repo.query(query, [slug, name, owner_id_binary, deleted])
[[id]] = result.rows
# Convert binary UUID back to string for comparisons
Ecto.UUID.load!(id)
end
defp count_maps_with_slug(slug) do
{:ok, result} = Repo.query("SELECT COUNT(*) FROM maps_v1 WHERE slug = $1", [slug])
[[count]] = result.rows
count
end
defp drop_unique_index do
# Drop the unique index to allow duplicate slugs for testing
Repo.query("DROP INDEX IF EXISTS maps_v1_unique_slug_index", [])
:ok
end
defp create_unique_index do
# Recreate the unique index (may fail if duplicates exist)
# Note: Index now applies to all maps, including deleted ones
Repo.query(
"""
CREATE UNIQUE INDEX IF NOT EXISTS maps_v1_unique_slug_index
ON maps_v1 (slug)
""",
[]
)
:ok
end
end

View File

@@ -155,7 +155,9 @@ defmodule WandererApp.Map.SlugUniquenessTest do
# Verify all successful maps have unique slugs
slugs = Enum.map(maps, & &1.slug)
assert length(Enum.uniq(slugs)) == length(slugs), "All successful maps should have unique slugs"
assert length(Enum.uniq(slugs)) == length(slugs),
"All successful maps should have unique slugs"
# Log results for visibility
Logger.info("Concurrent test: #{length(successful)} succeeded, #{length(failed)} failed")
@@ -309,12 +311,12 @@ defmodule WandererApp.Map.SlugUniquenessTest do
defp create_test_user do
# Create a test user with necessary attributes
{:ok, user} =
WandererApp.Api.User.new(%{
name: "Test User #{:rand.uniform(10_000)}",
eve_id: :rand.uniform(100_000_000)
})
user
case Ash.create(WandererApp.Api.User, %{
name: "Test User #{:rand.uniform(10_000)}",
hash: "test_hash_#{:rand.uniform(100_000_000)}"
}) do
{:ok, user} -> user
{:error, reason} -> raise "Failed to create user: #{inspect(reason)}"
end
end
end

View File

@@ -20,11 +20,13 @@ defmodule WandererApp.User.ActivityTrackerTest do
# - Invalid data
# The key requirement is that it NEVER crashes the calling code
result = ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil, # This will cause the function to skip tracking
user_id: nil,
map_id: nil
})
result =
ActivityTracker.track_map_event(:map_connection_added, %{
# This will cause the function to skip tracking
character_id: nil,
user_id: nil,
map_id: nil
})
# Should return success even when input is incomplete
assert {:ok, _} = result
@@ -35,11 +37,12 @@ defmodule WandererApp.User.ActivityTrackerTest do
# This is important for monitoring and debugging
# The function should complete without raising even with incomplete data
assert {:ok, _} = ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil,
user_id: nil,
map_id: nil
})
assert {:ok, _} =
ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil,
user_id: nil,
map_id: nil
})
end
end
@@ -49,10 +52,11 @@ defmodule WandererApp.User.ActivityTrackerTest do
end
test "returns {:ok, nil} on error without crashing" do
result = ActivityTracker.track_acl_event(:map_acl_added, %{
user_id: nil,
acl_id: nil
})
result =
ActivityTracker.track_acl_event(:map_acl_added, %{
user_id: nil,
acl_id: nil
})
assert {:ok, _} = result
end
@@ -68,11 +72,12 @@ defmodule WandererApp.User.ActivityTrackerTest do
# regardless of internal errors
# Test with nil values (which will fail validation)
assert {:ok, _} = ActivityTracker.track_map_event(:test_event, %{
character_id: nil,
user_id: nil,
map_id: nil
})
assert {:ok, _} =
ActivityTracker.track_map_event(:test_event, %{
character_id: nil,
user_id: nil,
map_id: nil
})
# Test with empty map (which will fail validation)
assert {:ok, _} = ActivityTracker.track_map_event(:test_event, %{})