From 22ba7231b10e2b0887b8eaa3f94f86b2e838c904 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 18 Feb 2026 23:46:18 +0000 Subject: [PATCH] Implement read-only database load at startup for CLI to get its settings. Full opening only when server has started. --- paskia/__main__.py | 48 ++++++++++++--------------------------- paskia/db/__init__.py | 2 ++ paskia/db/background.py | 3 ++- paskia/db/jsonl.py | 45 ++++++++++++++++++++++++++++++++++-- paskia/db/migrations.py | 16 +++++++++++++ paskia/fastapi/mainapp.py | 15 ++++++++++-- 6 files changed, 91 insertions(+), 38 deletions(-) diff --git a/paskia/__main__.py b/paskia/__main__.py index 60a9a46..dd7e282 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -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", diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index f90c73b..9b37c94 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -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", diff --git a/paskia/db/background.py b/paskia/db/background.py index e4f3a86..d1f8cd8 100644 --- a/paskia/db/background.py +++ b/paskia/db/background.py @@ -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 diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py index c0b952f..fbb3e42 100644 --- a/paskia/db/jsonl.py +++ b/paskia/db/jsonl.py @@ -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.""" diff --git a/paskia/db/migrations.py b/paskia/db/migrations.py index ccb5217..ceb1b4f 100644 --- a/paskia/db/migrations.py +++ b/paskia/db/migrations.py @@ -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, diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 88382fa..544e837 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -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: