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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 33 additions & 21 deletions docs/superpowers/specs/2026-08-08-apus-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,27 +761,39 @@ Oberfläche und Identity-Broker dazu kommen erst in Phase 5.
`BlueMapHosting`: Webserver-Deployment, Service, Ingress, Zertifikat, URL im Status.
Ergebnis: Karten sind unter eigener Adresse erreichbar. **Ende des MVP.**

### Phase 4 — Region-Sharding *(nach Spike)*

Vorgeschalteter **Spike**: Zwei Prozesse rendern gleichzeitig benachbarte, disjunkte
Regionsmengen in denselben Map-Storage; anschließend werden alle Zoomstufen auf Löcher und
veraltete Bereiche geprüft. Hintergrund: Lowres-Tiles mitteln über Regionsgrenzen hinweg,
weshalb konkurrierende Shards einander überschreiben könnten. Granulare Speicherung
verhindert Korruption, aber nicht notwendigerweise gegenseitiges Überschreiben aggregierter
Werte.

Fällt der Spike positiv aus: `shards: N` über `Job` mit `completionMode: Indexed`, jeder
Pod verarbeitet seinen Anteil der Regionsliste aus dem Manifest. Umsetzung über einen
eigenen Runner, der `scheduleMapUpdateTask(map, regions)` aufruft — öffentliche API, keine
Reflection. Nebeneffekte: Der Welt-Download parallelisiert mit, und der Fortschritt wird
genauer als BlueMaps eigene Schätzung, weil über bekannte Regionsanzahlen aggregiert wird.

Fällt der Spike negativ aus: Alternative ist ein zweistufiges Verfahren (Shards rendern
Hires-Tiles, ein abschließender Lauf baut die Lowres-Ebenen auf) oder der Verzicht auf
Sharding zugunsten vertikaler Skalierung.

Die Architektur ist bereits sharding-fähig ausgelegt: Regionsliste im Manifest,
`shards`-Feld in der CR, Fortschrittsaggregation im Operator.
### Phase 4 — Region-Sharding *(Spike durchgeführt, Ergebnis: kein Sharding)*

**Der Spike ist gelaufen und negativ ausgefallen.** Bericht:
`docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md`.

Gemessen wurde ein Referenzlauf (ganze Welt in einem Durchgang) gegen zwei gleichzeitig
laufende Container mit disjunkten, aneinandergrenzenden Regionsmengen im selben
Map-Storage. Ergebnis: **7 von 24 Lowres-Kacheln weichen ab**, dreimal reproduziert; eine
Kachel fällt von 99 % gerendertem Terrain auf 91 % leer. Ein sequenzieller Kontrolllauf
beschädigt sogar 10 von 24 Kacheln — die Reihenfolgeabhängigkeit bestätigt den Mechanismus
unabhängig vom Wettlauf.

Damit ist die frühere Annahme widerlegt, granulare Speicherung schütze ausreichend. Sie
verhindert Korruption einzelner Kacheln, aber nicht, dass zwei Shards dasselbe aggregierte
Lowres-Tile überschreiben.

**Entscheidung: kein Sharding.** Von den beiden in dieser Spec vorgesehenen Alternativen
wird die zweite gewählt — Verzicht zugunsten vertikaler Skalierung über `render-threads`.
Begründung:

- Das zweistufige Verfahren (Shards rendern nur Hires, ein finaler Lauf baut die
Lowres-Ebenen) setzt einen eigenen Runner mit Anbindung an BlueMap-Core voraus. Genau
den schließt §1.4 für den MVP aus, und §2.1 nennt den Grund: BlueMap-Core ist keine
stabile öffentliche API.
- Vertikale Skalierung ist bereits vorhanden und kostet nichts.
- Es gibt bislang keine Welt, deren Renderzeit das Problem rechtfertigt. Ohne diesen
Bedarf wäre Sharding Aufwand gegen ein hypothetisches Problem.

**Was von Phase 4 bleibt:** Die Architektur ist sharding-fähig ausgelegt — die Regionsliste
steht im Bundle-Manifest, `BlueMapMap.spec.shards` existiert. Sollte künftig eine Welt
tatsächlich zu lange brauchen, ist der zweistufige Weg der dann zu prüfende Ansatz, und
der Spike-Bericht ist die Grundlage dafür. `shards` bleibt bis dahin auf `1` beschränkt;
ein höherer Wert wird nicht umgesetzt und sollte vom Operator abgelehnt werden.

### Phase 5 — API, UI und Mandanten

Expand Down
311 changes: 311 additions & 0 deletions docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Transient working state from run-spike.sh / compare_tiles.py -- never committed.
mirrors/
logs/
__pycache__/
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Mirrors the lowres tile levels (tiles/1, tiles/2, tiles/3) of a bucket out of the
spike's MinIO via a throwaway `minio/mc` container, then diffs them byte-for-byte and
pixel-for-pixel against an already-mirrored `reference` bucket.

Usage:
python3 compare_tiles.py <bucket> <label> # mirrors <bucket> into mirrors/<label>/
# and diffs it against mirrors/reference/
python3 compare_tiles.py --summary <label> [<label> ...]
# prints the cross-run table used in the
# report, given labels already mirrored
# by prior --mirror-only/plain calls.

Requires: docker (for the mc mirror step) and Pillow (`pip install pillow`).
Run `./run-spike.sh` first so the buckets exist.
"""
from __future__ import annotations

import hashlib
import os
import subprocess
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
MIRRORS_DIR = os.path.join(SCRIPT_DIR, "mirrors")
NET = "apus-spike-net"
MC_IMAGE = "minio/mc:RELEASE.2025-08-13T08-35-41Z"
S3_USER = "apusspike"
S3_PASS = "apusspikesecret"
LEVELS = ["tiles-1", "tiles-2", "tiles-3"] # LOD1, LOD2, LOD3 -- the lowres pyramid


def mirror_bucket(bucket: str, label: str) -> None:
out_dir = os.path.join(MIRRORS_DIR, label)
os.makedirs(out_dir, exist_ok=True)
cmds = " && ".join(
[
f"mc alias set m http://minio:9000 {S3_USER} {S3_PASS} >/dev/null",
*[
f"mc mirror --overwrite --quiet m/{bucket}/demo/overworld/tiles/{lvl} /out/{label}/tiles-{lvl}"
for lvl in (1, 2, 3)
],
"echo MIRRORED",
]
)
subprocess.run(
[
"docker",
"run",
"--rm",
"--network",
NET,
"-v",
f"{MIRRORS_DIR}:/out",
"--entrypoint",
"/bin/sh",
MC_IMAGE,
"-c",
cmds,
],
check=True,
stdout=subprocess.DEVNULL,
)


def md5(path: str) -> str:
return hashlib.md5(open(path, "rb").read()).hexdigest()


def all_reference_files() -> list[str]:
ref_dir = os.path.join(MIRRORS_DIR, "reference")
files = []
for lvl in LEVELS:
d = os.path.join(ref_dir, lvl)
if not os.path.isdir(d):
continue
for root, _, names in os.walk(d):
for n in names:
files.append(os.path.relpath(os.path.join(root, n), ref_dir))
return sorted(files)


def pixel_diff_pct(ref_path: str, other_path: str) -> tuple[float, int]:
from PIL import Image
import numpy as np

a = np.array(Image.open(ref_path).convert("RGBA"), dtype=np.int16)
b = np.array(Image.open(other_path).convert("RGBA"), dtype=np.int16)
if a.shape != b.shape:
return 100.0, int(a[..., 0].size)
diff = np.abs(a - b)
npix = int((diff.max(axis=-1) > 0).sum())
return 100.0 * npix / diff[..., 0].size, npix


def diff_against_reference(label: str) -> list[str]:
ref_dir = os.path.join(MIRRORS_DIR, "reference")
other_dir = os.path.join(MIRRORS_DIR, label)
differing = []
for rel in all_reference_files():
ref_path = os.path.join(ref_dir, rel)
other_path = os.path.join(other_dir, rel)
if not os.path.exists(other_path):
print(f" MISSING: {rel}")
differing.append(rel)
continue
if md5(ref_path) == md5(other_path):
continue
pct, npix = pixel_diff_pct(ref_path, other_path)
print(f" DIFFERS: {rel} ({npix} px, {pct:.2f}%)")
differing.append(rel)
return differing


def main() -> None:
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)

if sys.argv[1] == "--summary":
labels = sys.argv[2:]
files = set()
for lbl in labels:
files.update(diff_against_reference(lbl))
# already printed per-label above; nothing more to do
return

bucket, label = sys.argv[1], sys.argv[2]
print(f"mirroring {bucket} -> mirrors/{label}/ ...")
mirror_bucket(bucket, label)
print(f"diffing mirrors/{label}/ against mirrors/reference/ (lowres only):")
differing = diff_against_reference(label)
total = len(all_reference_files())
print(f"{len(differing)}/{total} lowres tiles differ from the reference render")


if __name__ == "__main__":
main()
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
151 changes: 151 additions & 0 deletions docs/superpowers/spikes/2026-08-09-lowres-sharding-spike/run-spike.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Orchestrates the lowres-sharding spike end to end:
# 1. A private docker network + MinIO container (no published ports).
# 2. Seeds testdata/mini-world into a "bundles" bucket.
# 3. Reference render: the whole world, one process, no mask -> maps-reference.
# 4. N (default 3) concurrent-parallel renders: the world split at the z=512 region
# boundary into a "south" shard (r.-1.0 + r.0.0) and a "north" shard (r.-1.1 +
# r.0.1) via render-mask, both writing into the same fresh bucket at the same time
# -> maps-parallel-1..N.
# 5. One sequential control render: the same two shards, same shared bucket, but
# south fully completes before north starts (no race window) -> maps-sequential.
#
# Requires: docker, and apus/runner:dev already built (see runner/README.md). Run from
# anywhere; paths are resolved relative to this script and the repo root.
#
# Usage: ./run-spike.sh [repeats]
#
# Does not tear itself down -- see ./teardown.sh once you're done inspecting the
# buckets. Comparison/analysis is a separate step: see compare_tiles.py.
set -euo pipefail

REPEATS="${1:-3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
FIXTURE_DIR="${REPO_ROOT}/testdata/mini-world"
NET=apus-spike-net
MC_IMAGE=minio/mc:RELEASE.2025-08-13T08-35-41Z
MINIO_IMAGE=minio/minio:RELEASE.2024-11-07T00-52-20Z
S3_USER=apusspike
S3_PASS=apusspikesecret

mkdir -p "${SCRIPT_DIR}/logs"

echo "[spike] network + MinIO (no published ports)"
docker network create "$NET" >/dev/null 2>&1 || true
docker rm -f apus-spike-minio >/dev/null 2>&1 || true
docker run -d --name apus-spike-minio \
--network "$NET" --network-alias minio \
-e MINIO_ROOT_USER="$S3_USER" -e MINIO_ROOT_PASSWORD="$S3_PASS" \
"$MINIO_IMAGE" server /data >/dev/null
sleep 3

echo "[spike] seeding ${FIXTURE_DIR} into bundles/worlds/demo/v1"
docker run --rm --network "$NET" \
-v "${FIXTURE_DIR}:/fixture:ro" \
--entrypoint /bin/sh "$MC_IMAGE" -c "
mc alias set m http://minio:9000 ${S3_USER} ${S3_PASS} >/dev/null &&
mc mb --ignore-existing m/bundles &&
mc mb --ignore-existing m/maps-reference &&
mc mirror --quiet /fixture m/bundles/worlds/demo/v1 &&
echo SEEDED
"

REGION_COUNT=$(find "${FIXTURE_DIR}/region" -name '*.mca' | wc -l)
echo "[spike] fixture has ${REGION_COUNT} region files"

run_one() {
local bucket="$1" mask_min_z="$2" mask_max_z="$3" name="$4"
docker run --rm --network "$NET" --entrypoint /bin/sh "$MC_IMAGE" -c "
mc alias set m http://minio:9000 ${S3_USER} ${S3_PASS} >/dev/null &&
mc rb --force --dangerous m/${bucket} 2>/dev/null || true
mc mb --ignore-existing m/${bucket}
" >/dev/null
docker run -d --name "$name" --network "$NET" \
-e APUS_MAP_ID=overworld \
-e APUS_DIMENSION=minecraft:overworld \
-e APUS_MC_VERSION=1.21.10 \
-e APUS_WORLD_S3_URL=s3://bundles/worlds/demo/v1 \
-e APUS_MAP_BUCKET="$bucket" \
-e APUS_MAP_PREFIX=demo \
-e APUS_S3_ENDPOINT=http://minio:9000 \
-e APUS_S3_ACCESS_KEY="$S3_USER" \
-e APUS_S3_SECRET_KEY="$S3_PASS" \
-e APUS_RENDER_THREADS=4 \
-e APUS_TELEMETRY_ENABLED=false \
-e APUS_RENDER_MASK_MIN_X=-512 -e APUS_RENDER_MASK_MAX_X=511 \
-e APUS_RENDER_MASK_MIN_Z="$mask_min_z" -e APUS_RENDER_MASK_MAX_Z="$mask_max_z" \
-v "${SCRIPT_DIR}/spike-entrypoint.sh:/opt/apus/spike-entrypoint.sh:ro" \
--entrypoint /opt/apus/spike-entrypoint.sh \
apus/runner:dev
}

echo "[spike] reference render (whole world, one process, no mask) -> maps-reference"
docker rm -f apus-spike-reference >/dev/null 2>&1 || true
docker run --name apus-spike-reference --network "$NET" \
-e APUS_MAP_ID=overworld \
-e APUS_DIMENSION=minecraft:overworld \
-e APUS_MC_VERSION=1.21.10 \
-e APUS_WORLD_S3_URL=s3://bundles/worlds/demo/v1 \
-e APUS_MAP_BUCKET=maps-reference \
-e APUS_MAP_PREFIX=demo \
-e APUS_S3_ENDPOINT=http://minio:9000 \
-e APUS_S3_ACCESS_KEY="$S3_USER" \
-e APUS_S3_SECRET_KEY="$S3_PASS" \
-e APUS_RENDER_THREADS=4 \
-e APUS_TELEMETRY_ENABLED=false \
apus/runner:dev > "${SCRIPT_DIR}/logs/reference.log" 2>&1
docker rm apus-spike-reference >/dev/null

for i in $(seq 1 "$REPEATS"); do
bucket="maps-parallel-${i}"
echo "[spike] parallel repeat ${i}/${REPEATS}: south + north concurrently -> ${bucket}"
SOUTH_ID=$(run_one "$bucket" 0 511 "apus-spike-south-${i}")
NORTH_ID=$(run_one "$bucket" 512 1023 "apus-spike-north-${i}")
SOUTH_EXIT=$(docker wait "$SOUTH_ID")
NORTH_EXIT=$(docker wait "$NORTH_ID")
docker logs "apus-spike-south-${i}" > "${SCRIPT_DIR}/logs/${bucket}-south.log" 2>&1
docker logs "apus-spike-north-${i}" > "${SCRIPT_DIR}/logs/${bucket}-north.log" 2>&1
docker rm "apus-spike-south-${i}" "apus-spike-north-${i}" >/dev/null
echo "[spike] south exit=${SOUTH_EXIT} north exit=${NORTH_EXIT}"
if [ "$SOUTH_EXIT" != "0" ] || [ "$NORTH_EXIT" != "0" ]; then
echo "[spike] ERROR: repeat ${i} had a non-zero exit" >&2
exit 1
fi
done

echo "[spike] sequential control: south fully completes, then north starts -> maps-sequential"
docker run --rm --network "$NET" --entrypoint /bin/sh "$MC_IMAGE" -c "
mc alias set m http://minio:9000 ${S3_USER} ${S3_PASS} >/dev/null &&
mc rb --force --dangerous m/maps-sequential 2>/dev/null || true
mc mb --ignore-existing m/maps-sequential
" >/dev/null

docker run --name apus-spike-south-seq --network "$NET" \
-e APUS_MAP_ID=overworld -e APUS_DIMENSION=minecraft:overworld -e APUS_MC_VERSION=1.21.10 \
-e APUS_WORLD_S3_URL=s3://bundles/worlds/demo/v1 -e APUS_MAP_BUCKET=maps-sequential \
-e APUS_MAP_PREFIX=demo -e APUS_S3_ENDPOINT=http://minio:9000 \
-e APUS_S3_ACCESS_KEY="$S3_USER" -e APUS_S3_SECRET_KEY="$S3_PASS" \
-e APUS_RENDER_THREADS=4 -e APUS_TELEMETRY_ENABLED=false \
-e APUS_RENDER_MASK_MIN_X=-512 -e APUS_RENDER_MASK_MAX_X=511 \
-e APUS_RENDER_MASK_MIN_Z=0 -e APUS_RENDER_MASK_MAX_Z=511 \
-v "${SCRIPT_DIR}/spike-entrypoint.sh:/opt/apus/spike-entrypoint.sh:ro" \
--entrypoint /opt/apus/spike-entrypoint.sh \
apus/runner:dev > "${SCRIPT_DIR}/logs/sequential-south.log" 2>&1
docker rm apus-spike-south-seq >/dev/null

docker run --name apus-spike-north-seq --network "$NET" \
-e APUS_MAP_ID=overworld -e APUS_DIMENSION=minecraft:overworld -e APUS_MC_VERSION=1.21.10 \
-e APUS_WORLD_S3_URL=s3://bundles/worlds/demo/v1 -e APUS_MAP_BUCKET=maps-sequential \
-e APUS_MAP_PREFIX=demo -e APUS_S3_ENDPOINT=http://minio:9000 \
-e APUS_S3_ACCESS_KEY="$S3_USER" -e APUS_S3_SECRET_KEY="$S3_PASS" \
-e APUS_RENDER_THREADS=4 -e APUS_TELEMETRY_ENABLED=false \
-e APUS_RENDER_MASK_MIN_X=-512 -e APUS_RENDER_MASK_MAX_X=511 \
-e APUS_RENDER_MASK_MIN_Z=512 -e APUS_RENDER_MASK_MAX_Z=1023 \
-v "${SCRIPT_DIR}/spike-entrypoint.sh:/opt/apus/spike-entrypoint.sh:ro" \
--entrypoint /opt/apus/spike-entrypoint.sh \
apus/runner:dev > "${SCRIPT_DIR}/logs/sequential-north.log" 2>&1
docker rm apus-spike-north-seq >/dev/null

echo "[spike] done. Buckets: maps-reference, maps-parallel-1..${REPEATS}, maps-sequential."
echo "[spike] next: python3 compare_tiles.py <bucket> <label> for each, then inspect mirrors/."
Loading