Implement read-only database load at startup for CLI to get its settings. Full opening only when server has started.

This commit is contained in:
2026-02-18 23:46:18 +00:00
parent 9a9979fb62
commit 22ba7231b1
6 changed files with 91 additions and 38 deletions
+15 -33
View File
@@ -8,12 +8,8 @@ from urllib.parse import urlparse
from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from paskia import db
from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig
from paskia.db.background import flush
from paskia.db.structs import Config
from paskia.db.jsonl import load_readonly
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
@@ -104,9 +100,10 @@ def main():
if getattr(args, "listen", None) == "":
args.listen = None
# Init db and load stored config
asyncio.run(db.init(rp_id=args.rp_id))
stored_config = db.data().config
# Read-only load to get stored config (no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
stored_db = asyncio.run(load_readonly(db_path, rp_id=args.rp_id))
stored_config = stored_db.config
# Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None:
@@ -168,6 +165,14 @@ def main():
)
# Export configuration via single JSON env variable for worker processes
# Include cli_config and save flag so lifespan can handle bootstrap/persistence
cli_config = {
"rp_id": args.rp_id,
"rp_name": args.rp_name,
"origins": args.origins,
"auth_host": args.auth_host,
"listen": args.listen,
}
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
@@ -175,36 +180,13 @@ def main():
"auth_host": config.auth_host,
"site_url": config.site_url,
"site_path": config.site_path,
"save": args.save,
"cli_config": cli_config,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
startupbox.print_startup_config(config)
# Build config to save (for bootstrap or explicit --save)
cli_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
async def startup():
await _globals.init(
rp_id=config.rp_id,
rp_name=config.rp_name,
origins=config.origins,
bootstrap=False,
)
# Pass config to bootstrap - it will be saved within the bootstrap transaction
await bootstrap_if_needed(config=cli_config)
# Also save config if --save was explicitly used (even without bootstrap)
if args.save:
await db.update_config(cli_config)
await flush()
asyncio.run(startup())
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
server.run(
"paskia.fastapi.mainapp:app",
+2
View File
@@ -27,6 +27,7 @@ from paskia.db.background import (
stop_cleanup,
)
from paskia.db.bootstrap import bootstrap
from paskia.db.jsonl import load_readonly
from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import (
add_permission_to_org,
@@ -102,6 +103,7 @@ __all__ = [
# Instance
"data",
"init",
"load_readonly",
# Background
"start_background",
"stop_background",
+2 -1
View File
@@ -90,7 +90,7 @@ async def start_background():
async def stop_background():
"""Stop the background task and flush any pending changes."""
"""Stop the background task, flush pending changes, and release the file lock."""
global _background_task
if _background_task:
_background_task.cancel()
@@ -99,6 +99,7 @@ async def stop_background():
except asyncio.CancelledError:
pass
_background_task = None
_ops._store.close()
# Aliases for backwards compatibility
+43 -2
View File
@@ -18,8 +18,8 @@ import jsondiff
import msgspec
from paskia.db.logging import log_change
from paskia.db.migrations import DBVER, apply_all_migrations
from paskia.db.structs import DB, SessionContext
from paskia.db.migrations import DBVER, apply_all_migrations, apply_migrations_readonly
from paskia.db.structs import DB, Config, SessionContext
_logger = logging.getLogger(__name__)
@@ -27,6 +27,47 @@ _logger = logging.getLogger(__name__)
DB_PATH_DEFAULT = "paskia.jsonl"
async def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
This is suitable for reading settings before the server starts.
Migrations are applied in-memory only; nothing is queued or flushed.
"""
path = Path(db_path)
if not path.exists():
return DB(config=Config(rp_id=rp_id))
data_dict: dict = {}
version = 0
try:
async with aiofiles.open(path, "rb") as f:
content = await f.read()
for line_num, line in enumerate(content.split(b"\n"), 1):
line = line.strip()
if not line:
continue
try:
change = msgspec.json.decode(line)
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except OSError as e:
raise SystemExit(f"Failed to load database: {e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"Failed to load database: {e}")
if not data_dict:
return DB(config=Config(rp_id=rp_id))
# Apply migrations in-memory (no persistence)
apply_migrations_readonly(data_dict, version, rp_id=rp_id)
# Decode to msgspec struct
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
return db
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
"""A single change record in the JSONL file."""
+16
View File
@@ -45,6 +45,22 @@ migrations = sorted(
DBVER = len(migrations) # Used by bootstrap to set initial version
def apply_migrations_readonly(
data_dict: dict,
current_version: int,
*,
rp_id: str = "localhost",
) -> int:
"""Apply migration functions in-place without persistence.
Returns the new version after all migrations.
"""
while current_version < DBVER:
migrations[current_version](data_dict, rp_id=rp_id)
current_version += 1
return current_version
async def apply_all_migrations(
data_dict: dict,
current_version: int,
+13 -2
View File
@@ -7,10 +7,13 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from paskia import authcode, globals
from paskia import authcode, db, globals
from paskia.__main__ import DEVMODE
from paskia.bootstrap import bootstrap_if_needed
from paskia.db import start_background, stop_background
from paskia.db.background import flush
from paskia.db.logging import configure_db_logging
from paskia.db.structs import Config
from paskia.fastapi import admin, api, auth_host, oid, ws
# Import frontend instance
@@ -40,7 +43,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
config = json.loads(os.environ["PASKIA_CONFIG"])
try:
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
await globals.init(
rp_id=config["rp_id"],
rp_name=config["rp_name"],
@@ -52,6 +54,15 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Re-raise to fail fast
raise
# Bootstrap and persist config now that the full DB is loaded
cli_config_data = config.get("cli_config")
if cli_config_data:
cli_config = Config(**cli_config_data)
await bootstrap_if_needed(config=cli_config)
if config.get("save"):
await db.update_config(cli_config)
await flush()
# Restore uvicorn info logging (suppressed during startup in dev mode)
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
if app.debug: