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:
+15
-33
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user