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 import server
from fastapi_vue.hostutil import parse_endpoints 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.config import PaskiaConfig
from paskia.db.background import flush from paskia.db.jsonl import load_readonly
from paskia.db.structs import Config
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import normalize_origin
@@ -104,9 +100,10 @@ def main():
if getattr(args, "listen", None) == "": if getattr(args, "listen", None) == "":
args.listen = None args.listen = None
# Init db and load stored config # Read-only load to get stored config (no writes, no global state)
asyncio.run(db.init(rp_id=args.rp_id)) db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
stored_config = db.data().config stored_db = asyncio.run(load_readonly(db_path, rp_id=args.rp_id))
stored_config = stored_db.config
# Apply defaults from stored config # Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None: 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 # 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 = { config_json = {
"rp_id": config.rp_id, "rp_id": config.rp_id,
"rp_name": config.rp_name, "rp_name": config.rp_name,
@@ -175,36 +180,13 @@ def main():
"auth_host": config.auth_host, "auth_host": config.auth_host,
"site_url": config.site_url, "site_url": config.site_url,
"site_path": config.site_path, "site_path": config.site_path,
"save": args.save,
"cli_config": cli_config,
} }
os.environ["PASKIA_CONFIG"] = json.dumps(config_json) os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
startupbox.print_startup_config(config) 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 {} dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
server.run( server.run(
"paskia.fastapi.mainapp:app", "paskia.fastapi.mainapp:app",
+2
View File
@@ -27,6 +27,7 @@ from paskia.db.background import (
stop_cleanup, stop_cleanup,
) )
from paskia.db.bootstrap import bootstrap from paskia.db.bootstrap import bootstrap
from paskia.db.jsonl import load_readonly
from paskia.db.lifecycle import cleanup_expired, init from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import ( from paskia.db.operations import (
add_permission_to_org, add_permission_to_org,
@@ -102,6 +103,7 @@ __all__ = [
# Instance # Instance
"data", "data",
"init", "init",
"load_readonly",
# Background # Background
"start_background", "start_background",
"stop_background", "stop_background",
+2 -1
View File
@@ -90,7 +90,7 @@ async def start_background():
async def stop_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 global _background_task
if _background_task: if _background_task:
_background_task.cancel() _background_task.cancel()
@@ -99,6 +99,7 @@ async def stop_background():
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_background_task = None _background_task = None
_ops._store.close()
# Aliases for backwards compatibility # Aliases for backwards compatibility
+43 -2
View File
@@ -18,8 +18,8 @@ import jsondiff
import msgspec import msgspec
from paskia.db.logging import log_change from paskia.db.logging import log_change
from paskia.db.migrations import DBVER, apply_all_migrations from paskia.db.migrations import DBVER, apply_all_migrations, apply_migrations_readonly
from paskia.db.structs import DB, SessionContext from paskia.db.structs import DB, Config, SessionContext
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -27,6 +27,47 @@ _logger = logging.getLogger(__name__)
DB_PATH_DEFAULT = "paskia.jsonl" 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): class _ChangeRecord(msgspec.Struct, omit_defaults=True):
"""A single change record in the JSONL file.""" """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 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( async def apply_all_migrations(
data_dict: dict, data_dict: dict,
current_version: int, current_version: int,
+13 -2
View File
@@ -7,10 +7,13 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from paskia import authcode, globals from paskia import authcode, db, globals
from paskia.__main__ import DEVMODE from paskia.__main__ import DEVMODE
from paskia.bootstrap import bootstrap_if_needed
from paskia.db import start_background, stop_background 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.logging import configure_db_logging
from paskia.db.structs import Config
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
# Import frontend instance # Import frontend instance
@@ -40,7 +43,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
config = json.loads(os.environ["PASKIA_CONFIG"]) config = json.loads(os.environ["PASKIA_CONFIG"])
try: try:
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
await globals.init( await globals.init(
rp_id=config["rp_id"], rp_id=config["rp_id"],
rp_name=config["rp_name"], rp_name=config["rp_name"],
@@ -52,6 +54,15 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Re-raise to fail fast # Re-raise to fail fast
raise 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) # Restore uvicorn info logging (suppressed during startup in dev mode)
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
if app.debug: if app.debug: