feat(startup): 服务端启动 bootstrap 与缓存预热

- 新增 STARTUP_BOOTSTRAP_ENABLED / STARTUP_CACHE_WARMUP_ENABLED 配置开关
- lifespan 拆分 bootstrap 步骤并后台线程预热缓存,失败可降级继续启动
- server_start.sh / web_start.sh 扩展 SERVER_PORT、启动与调度开关的 env 覆盖
- bootstrap_paddleocr_mobile.sh 改用 python3 并补充 poppler-utils 依赖
- 补充启动 bootstrap 与 env 覆盖优先级测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:11:37 +08:00
parent 35372c6661
commit 59ba76c74a
7 changed files with 236 additions and 8 deletions

View File

@@ -3,17 +3,17 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312" OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
PYTHON_BIN="${PYTHON_BIN:-python3.12}" PYTHON_BIN="${PYTHON_BIN:-python3}"
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}" PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.2.2}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}" PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2 echo "${PYTHON_BIN} 不存在,请先安装 Python 3。" >&2
exit 1 exit 1
fi fi
apt-get update apt-get update
apt-get install -y libgl1 libglib2.0-0 apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}" "${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip "${OCR_VENV_DIR}/bin/pip" install --upgrade pip

View File

@@ -88,8 +88,11 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then
fi fi
ENV_OVERRIDE_SERVER_HOST_SET=false ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_SERVER_PORT_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false ENV_OVERRIDE_POSTGRES_HOST_SET=false
ENV_OVERRIDE_DATABASE_URL_SET=false ENV_OVERRIDE_DATABASE_URL_SET=false
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=false
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
@@ -107,6 +110,11 @@ if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi fi
if [ "${SERVER_PORT+x}" = x ]; then
ENV_OVERRIDE_SERVER_PORT_SET=true
ENV_OVERRIDE_SERVER_PORT="$SERVER_PORT"
fi
if [ "${POSTGRES_HOST+x}" = x ]; then if [ "${POSTGRES_HOST+x}" = x ]; then
ENV_OVERRIDE_POSTGRES_HOST_SET=true ENV_OVERRIDE_POSTGRES_HOST_SET=true
ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST" ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST"
@@ -117,6 +125,16 @@ if [ "${DATABASE_URL+x}" = x ]; then
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL" ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
fi fi
if [ "${STARTUP_BOOTSTRAP_ENABLED+x}" = x ]; then
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=true
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED="$STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "${BACKGROUND_SCHEDULERS_ENABLED+x}" = x ]; then
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=true
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED="$BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED" ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
@@ -145,6 +163,10 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi fi
if [ "$ENV_OVERRIDE_SERVER_PORT_SET" = true ]; then
SERVER_PORT="$ENV_OVERRIDE_SERVER_PORT"
fi
if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then
POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST" POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST"
fi fi
@@ -153,6 +175,14 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL" DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
fi fi
if [ "$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET" = true ]; then
STARTUP_BOOTSTRAP_ENABLED="$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET" = true ]; then
BACKGROUND_SCHEDULERS_ENABLED="$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED" ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
fi fi

View File

@@ -43,6 +43,8 @@ class Settings(BaseSettings):
app_port: int = Field(default=8000, alias="SERVER_PORT") app_port: int = Field(default=8000, alias="SERVER_PORT")
server_workers: int = Field(default=1, alias="SERVER_WORKERS") server_workers: int = Field(default=1, alias="SERVER_WORKERS")
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY") web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
startup_bootstrap_enabled: bool = Field(default=True, alias="STARTUP_BOOTSTRAP_ENABLED")
startup_cache_warmup_enabled: bool = Field(default=False, alias="STARTUP_CACHE_WARMUP_ENABLED")
background_schedulers_enabled: bool = Field( background_schedulers_enabled: bool = Field(
default=True, default=True,
alias="BACKGROUND_SCHEDULERS_ENABLED", alias="BACKGROUND_SCHEDULERS_ENABLED",

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from logging import Logger
import threading
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -10,11 +12,13 @@ from app.api.router import api_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation from app.services.agent_foundation import prepare_agent_foundation
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory from app.services.employee import prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee_profile_scheduler import employee_profile_scheduler from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler from app.services.finance_report_scheduler import finance_report_scheduler
@@ -23,6 +27,8 @@ from app.services.knowledge import prepare_knowledge_library
from app.services.knowledge_index_tasks import knowledge_index_task_manager from app.services.knowledge_index_tasks import knowledge_index_task_manager
from app.services.knowledge_rag import shutdown_knowledge_rag_runtime from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler from app.services.knowledge_scheduler import knowledge_index_scheduler
from app.services.settings import SettingsService
from app.services.user_session_metrics import UserSessionMetricService
def _effective_server_workers(settings: object) -> int: def _effective_server_workers(settings: object) -> int:
@@ -42,15 +48,55 @@ def _should_start_background_schedulers(settings: object) -> bool:
return _effective_server_workers(settings) <= 1 return _effective_server_workers(settings) <= 1
def _run_startup_bootstrap(logger: Logger) -> None:
steps = (
("employee_directory", prepare_employee_directory),
("agent_foundation", prepare_agent_foundation),
("knowledge_library", prepare_knowledge_library),
("hermes_skills", sync_repository_hermes_skills),
)
for name, step in steps:
try:
step()
except Exception:
logger.exception("Startup bootstrap step failed; continuing degraded name=%s", name)
def _warm_startup_caches(logger: Logger) -> None:
try:
session_factory = get_session_factory()
with session_factory() as db:
SettingsService(db).ensure_settings_ready()
EmployeeService(db).ensure_directory_ready()
UserSessionMetricService(db).ensure_storage_ready()
logger.info("Startup cache warmup complete")
except Exception:
logger.exception("Startup cache warmup failed; continuing without warm cache")
def _start_cache_warmup_thread(logger: Logger) -> None:
thread = threading.Thread(
target=_warm_startup_caches,
args=(logger,),
name="x-financial-startup-cache-warmup",
daemon=True,
)
thread.start()
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings() settings = get_settings()
logger = get_logger("app.main") logger = get_logger("app.main")
prepare_employee_directory() if settings.startup_bootstrap_enabled:
prepare_agent_foundation() _run_startup_bootstrap(logger)
prepare_knowledge_library() else:
sync_repository_hermes_skills() logger.warning("Startup bootstrap skipped because STARTUP_BOOTSTRAP_ENABLED=false")
if settings.startup_cache_warmup_enabled:
_start_cache_warmup_thread(logger)
schedulers_started = _should_start_background_schedulers(settings) schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started: if schedulers_started:
knowledge_index_scheduler.start() knowledge_index_scheduler.start()

View File

@@ -79,3 +79,63 @@ def test_root_start_can_prefer_env_file_over_inherited_onlyoffice_values(tmp_pat
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert "ONLYOFFICE_ENABLED=true" in result.stdout assert "ONLYOFFICE_ENABLED=true" in result.stdout
assert "ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082" in result.stdout assert "ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082" in result.stdout
def test_web_start_preserves_inherited_runtime_ports(tmp_path: Path) -> None:
result = _run_script_prefix(
tmp_path,
"web/web_start.sh",
env_file_content=(
"WEB_HOST=10.10.10.122\n"
"WEB_PORT=5273\n"
"SERVER_HOST=10.10.10.122\n"
"SERVER_PORT=9000\n"
"POSTGRES_HOST=10.10.10.189\n"
),
env={
"WEB_HOST": "0.0.0.0",
"WEB_PORT": "5173",
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": "8000",
"POSTGRES_HOST": "www.caoxiaozhu.com",
},
output_vars=["WEB_HOST", "WEB_PORT", "SERVER_HOST", "SERVER_PORT", "POSTGRES_HOST"],
)
assert result.returncode == 0, result.stderr
assert "WEB_HOST=0.0.0.0" in result.stdout
assert "WEB_PORT=5173" in result.stdout
assert "SERVER_HOST=0.0.0.0" in result.stdout
assert "SERVER_PORT=8000" in result.stdout
assert "POSTGRES_HOST=www.caoxiaozhu.com" in result.stdout
def test_server_start_preserves_inherited_runtime_guards(tmp_path: Path) -> None:
result = _run_script_prefix(
tmp_path,
"server/server_start.sh",
env_file_content=(
"SERVER_HOST=10.10.10.122\n"
"SERVER_PORT=9000\n"
"STARTUP_BOOTSTRAP_ENABLED=true\n"
"BACKGROUND_SCHEDULERS_ENABLED=true\n"
),
env={
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": "8000",
"STARTUP_BOOTSTRAP_ENABLED": "false",
"BACKGROUND_SCHEDULERS_ENABLED": "false",
},
output_vars=[
"SERVER_HOST",
"SERVER_PORT",
"STARTUP_BOOTSTRAP_ENABLED",
"BACKGROUND_SCHEDULERS_ENABLED",
],
)
assert result.returncode == 0, result.stderr
assert "SERVER_HOST=0.0.0.0" in result.stdout
assert "SERVER_PORT=8000" in result.stdout
assert "STARTUP_BOOTSTRAP_ENABLED=false" in result.stdout
assert "BACKGROUND_SCHEDULERS_ENABLED=false" in result.stdout

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core.config import get_settings
import app.main as main_module
def _set_bootstrap_step(
monkeypatch,
name: str,
calls: list[str],
*,
should_fail: bool = False,
) -> None:
def step() -> None:
calls.append(name)
if should_fail:
raise RuntimeError(f"{name} failed")
monkeypatch.setattr(main_module, name, step)
def test_lifespan_can_skip_startup_bootstrap(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setenv("STARTUP_BOOTSTRAP_ENABLED", "false")
monkeypatch.setenv("BACKGROUND_SCHEDULERS_ENABLED", "false")
get_settings.cache_clear()
for name in (
"prepare_employee_directory",
"prepare_agent_foundation",
"prepare_knowledge_library",
"sync_repository_hermes_skills",
):
_set_bootstrap_step(monkeypatch, name, calls)
try:
with TestClient(main_module.create_app()) as client:
response = client.get("/")
finally:
get_settings.cache_clear()
assert response.status_code == 200
assert calls == []
def test_lifespan_continues_when_startup_bootstrap_fails(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setenv("STARTUP_BOOTSTRAP_ENABLED", "true")
monkeypatch.setenv("BACKGROUND_SCHEDULERS_ENABLED", "false")
get_settings.cache_clear()
steps: tuple[tuple[str, bool], ...] = (
("prepare_employee_directory", True),
("prepare_agent_foundation", False),
("prepare_knowledge_library", False),
("sync_repository_hermes_skills", False),
)
for name, should_fail in steps:
_set_bootstrap_step(monkeypatch, name, calls, should_fail=should_fail)
try:
with TestClient(main_module.create_app()) as client:
response = client.get("/")
finally:
get_settings.cache_clear()
assert response.status_code == 200
assert calls == [name for name, _ in steps]

View File

@@ -29,7 +29,9 @@ error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; }
if [ -f "$ROOT_ENV_FILE" ]; then if [ -f "$ROOT_ENV_FILE" ]; then
ENV_OVERRIDE_WEB_HOST_SET=false ENV_OVERRIDE_WEB_HOST_SET=false
ENV_OVERRIDE_WEB_PORT_SET=false
ENV_OVERRIDE_SERVER_HOST_SET=false ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_SERVER_PORT_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false ENV_OVERRIDE_POSTGRES_HOST_SET=false
if [ "${WEB_HOST+x}" = x ]; then if [ "${WEB_HOST+x}" = x ]; then
@@ -37,11 +39,21 @@ if [ -f "$ROOT_ENV_FILE" ]; then
ENV_OVERRIDE_WEB_HOST="$WEB_HOST" ENV_OVERRIDE_WEB_HOST="$WEB_HOST"
fi fi
if [ "${WEB_PORT+x}" = x ]; then
ENV_OVERRIDE_WEB_PORT_SET=true
ENV_OVERRIDE_WEB_PORT="$WEB_PORT"
fi
if [ "${SERVER_HOST+x}" = x ]; then if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST_SET=true ENV_OVERRIDE_SERVER_HOST_SET=true
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi fi
if [ "${SERVER_PORT+x}" = x ]; then
ENV_OVERRIDE_SERVER_PORT_SET=true
ENV_OVERRIDE_SERVER_PORT="$SERVER_PORT"
fi
if [ "${POSTGRES_HOST+x}" = x ]; then if [ "${POSTGRES_HOST+x}" = x ]; then
ENV_OVERRIDE_POSTGRES_HOST_SET=true ENV_OVERRIDE_POSTGRES_HOST_SET=true
ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST" ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST"
@@ -55,10 +67,18 @@ if [ -f "$ROOT_ENV_FILE" ]; then
WEB_HOST="$ENV_OVERRIDE_WEB_HOST" WEB_HOST="$ENV_OVERRIDE_WEB_HOST"
fi fi
if [ "$ENV_OVERRIDE_WEB_PORT_SET" = true ]; then
WEB_PORT="$ENV_OVERRIDE_WEB_PORT"
fi
if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi fi
if [ "$ENV_OVERRIDE_SERVER_PORT_SET" = true ]; then
SERVER_PORT="$ENV_OVERRIDE_SERVER_PORT"
fi
if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then
POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST" POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST"
fi fi