perf(appstore): быстрый синк Umbrel — иконки с CDN параллельно + стартовый синк
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s

A1: иконки Umbrel тянутся пофайлово с GitHub Pages CDN в пуле из 16 потоков
(<app-id>/icon.svg) вместо тяжёлого tarball галереи со скриншотами. Синк
Umbrel: ~7.5 мин → ~13-17с, 379/379 иконок. Настройка UMBREL_APPSTORE_GALLERY_CDN
заменяет UMBREL_APPSTORE_GALLERY_REPO. 404/сетевой сбой отдельного запроса
просто пропускает иконку, не роняя синк.

A2: на свежем деплое каталог пуст, а IntervalTrigger впервые сработает лишь
через сутки. Теперь при пустом каталоге планировщик ставит разовый синк
немедленно (trigger=date) — каталог наполняется на старте без ручной кнопки.

Проверено на стенде: стартовый синк наполнил каталог 644 (runtipi 265 +
umbrel 379) за 16.5с; эндпоинт логотипа отдаёт 200 image/svg+xml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-09 01:40:39 +05:00
parent ebc1c8429e
commit aebb726231
4 changed files with 62 additions and 27 deletions

View File

@@ -788,7 +788,7 @@ Catalog of self-hosted applications with one-click install. Each app runs in its
| `RUNTIPI_APPSTORE_REF` | `master` | branch/tag/commit (pin recommended) |
| `UMBREL_APPSTORE_REPO` | `getumbrel/umbrel-apps` | Umbrel catalog source (used when `umbrel` is enabled) |
| `UMBREL_APPSTORE_REF` | `master` | Umbrel branch/tag/commit |
| `UMBREL_APPSTORE_GALLERY_REPO` | `getumbrel/umbrel-apps-gallery` | Umbrel icons source (`<app-id>/icon.svg`) |
| `UMBREL_APPSTORE_GALLERY_CDN` | `https://getumbrel.github.io/umbrel-apps-gallery` | Umbrel icons CDN (`<app-id>/icon.svg`, fetched per-file in parallel) |
| `APPSTORE_DATA_DIR` | `/app/data/appstore` | logo cache (mounted volume) |
| `CATALOG_SYNC_INTERVAL_HOURS` | `24` | auto catalog sync interval |
| `APPSTORE_HOST_ARCH` | `amd64` | target node arch (marks unsupported apps) |

View File

@@ -63,5 +63,5 @@ APPSTORE_SOURCES=runtipi
# Репозитории Umbrel (нужны только если включён источник umbrel):
# UMBREL_APPSTORE_REPO=getumbrel/umbrel-apps
# UMBREL_APPSTORE_REF=master
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — скачиваются одним tarball:
# UMBREL_APPSTORE_GALLERY_REPO=getumbrel/umbrel-apps-gallery
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — тянутся пофайлово с CDN:
# UMBREL_APPSTORE_GALLERY_CDN=https://getumbrel.github.io/umbrel-apps-gallery

View File

@@ -82,10 +82,12 @@ class Settings(BaseSettings):
# отдельным провайдером; см. UmbrelCatalogProvider.
UMBREL_APPSTORE_REPO: str = Field(default="getumbrel/umbrel-apps", env="UMBREL_APPSTORE_REPO")
UMBREL_APPSTORE_REF: str = Field(default="master", env="UMBREL_APPSTORE_REF")
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в отдельном репозитории
# галереи (<app-id>/icon.svg). Скачивается одним tarball при синхронизации.
UMBREL_APPSTORE_GALLERY_REPO: str = Field(
default="getumbrel/umbrel-apps-gallery", env="UMBREL_APPSTORE_GALLERY_REPO")
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в галерее
# (<app-id>/icon.svg). Тянем их пофайлово с GitHub Pages CDN параллельно —
# это быстрее, чем качать тяжёлый tarball галереи (со скриншотами).
UMBREL_APPSTORE_GALLERY_CDN: str = Field(
default="https://getumbrel.github.io/umbrel-apps-gallery",
env="UMBREL_APPSTORE_GALLERY_CDN")
# Активные источники каталога (через запятую): "runtipi", "umbrel" или оба.
# По умолчанию только runtipi — Umbrel включается явно.
APPSTORE_SOURCES: str = Field(default="runtipi", env="APPSTORE_SOURCES")

View File

@@ -17,6 +17,7 @@ import os
import tarfile
import tempfile
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
@@ -151,10 +152,10 @@ class UmbrelCatalogProvider(CatalogProvider):
PREFIX = "umbrel-"
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None,
gallery_repo: Optional[str] = None):
gallery_cdn: Optional[str] = None):
self.repo = repo or settings.UMBREL_APPSTORE_REPO
self.ref = ref or settings.UMBREL_APPSTORE_REF
self.gallery_repo = gallery_repo or settings.UMBREL_APPSTORE_GALLERY_REPO
self.gallery_cdn = gallery_cdn or settings.UMBREL_APPSTORE_GALLERY_CDN
def fetch(self) -> Dict[str, AppRaw]:
archive = self._download_tarball(self.repo, self.ref)
@@ -191,38 +192,48 @@ class UmbrelCatalogProvider(CatalogProvider):
except Exception:
pass
# Иконки — из отдельного репозитория галереи (<app-id>/icon.svg). Сбой
# Иконки — из галереи Umbrel (<app-id>/icon.svg), пофайлово с CDN. Сбой
# получения иконок не должен ронять синк каталога.
try:
icons = self._fetch_gallery_icons()
raw_ids = [app_id[len(self.PREFIX):] for app_id in apps]
icons = self._fetch_gallery_icons(raw_ids)
for app_id, raw in apps.items():
data = icons.get(app_id[len(self.PREFIX):])
if data:
raw.logo_bytes = data
raw.logo_ext = ".svg"
logger.info(f"[appstore] umbrel icons fetched: {len(icons)}/{len(apps)}")
except Exception as e:
logger.warning(f"[appstore] umbrel gallery icons failed: {e}")
return apps
def _fetch_gallery_icons(self) -> Dict[str, bytes]:
"""Скачать tarball галереи Umbrel и вернуть словарь raw_id → bytes(icon.svg)."""
archive = self._download_tarball(self.gallery_repo, self.ref)
icons: Dict[str, bytes] = {}
try:
with tarfile.open(archive, mode="r:gz") as tar:
for member in tar:
if not member.isfile():
continue
# путь вида "<repo>-<ref>/<app-id>/icon.svg"
parts = member.name.split("/")
if len(parts) == 3 and parts[2].lower() == "icon.svg":
icons[parts[1]] = self._read(tar, member)
finally:
def _fetch_gallery_icons(self, raw_ids: List[str]) -> Dict[str, bytes]:
"""Скачать icon.svg каждого приложения параллельно с CDN галереи Umbrel.
Быстрее тяжёлого tarball галереи (тот тянет и скриншоты). HTTP 404 → у
приложения нет иконки (не ошибка); сетевой сбой отдельного запроса просто
пропускает иконку, не роняя синк.
"""
base = (self.gallery_cdn or "").rstrip("/")
if not base or not raw_ids:
return {}
def _one(raw_id: str):
url = f"{base}/{raw_id}/icon.svg"
try:
archive.unlink()
r = httpx.get(url, timeout=15, follow_redirects=True)
if r.status_code == 200 and r.content:
return raw_id, r.content
except Exception:
pass
return None
return None
icons: Dict[str, bytes] = {}
with ThreadPoolExecutor(max_workers=16, thread_name_prefix="umbrel_icons") as ex:
for res in ex.map(_one, raw_ids):
if res:
icons[res[0]] = res[1]
return icons
@staticmethod
@@ -483,6 +494,28 @@ def start_catalog_scheduler():
replace_existing=True,
misfire_grace_time=3600,
)
# Стартовый синк: на свежем деплое каталог пуст, а IntervalTrigger впервые
# сработает только через CATALOG_SYNC_INTERVAL_HOURS. Чтобы не ждать 24ч,
# запускаем разовый синк немедленно в фоне (не блокируя старт приложения).
try:
db = SessionLocal()
try:
is_empty = db.query(CatalogApp).count() == 0
finally:
db.close()
if is_empty:
aps.add_job(
_scheduled_sync,
trigger="date", # run_date по умолчанию = сейчас → разовый запуск
id="appstore_catalog_initial_sync",
replace_existing=True,
misfire_grace_time=600,
)
logger.info("App Store catalog empty — initial sync scheduled")
except Exception as e:
logger.warning(f"App Store initial catalog sync check failed: {e}")
if not aps.running:
aps.start()
logger.info(