From 09a5d6ed55239690286ce92746593560d1ce7315 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 19:36:39 +0200 Subject: [PATCH] Polish account-creation flow: insertion point, user_id consistency, startup race Three small fixes shaken out by live testing on aio-demo: 1. fava_client.add_account: when the target file has no Open directives yet (e.g. the empty accounts/users.beancount seed), append at end of file instead of inserting at index 0. Keeps the seed header comments at the top where they belong. 2. account_sync.sync_single_account_from_beancount: read the full user_id from Beancount metadata when present, fall back to the name-derived 8-char prefix otherwise. crud.get_or_create_user_account writes the full 32-char user_id into Beancount metadata when creating per-user accounts; the sync function was only looking at the account name and returning the prefix, so the post-sync `WHERE user_id=:user_id` query in crud.py missed the row and fell through the UNIQUE-constraint recovery path. Three lines of warning noise per user-account creation. 3. tasks.wait_for_account_sync: await `wait_for_fava_client()` (new helper backed by an asyncio.Event in fava_client.py) before the first sync iteration. Previously the sync task started in libra_start() raced the fire-and-forget `_init_fava()` coroutine and reliably crashed the first run with "Fava client not initialized". Refs: aiolabs/libra#28 --- account_sync.py | 11 ++++++++++- fava_client.py | 25 +++++++++++++++++++++++-- tasks.py | 7 +++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/account_sync.py b/account_sync.py index 7e875f8..3d82381 100644 --- a/account_sync.py +++ b/account_sync.py @@ -320,11 +320,20 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: # Create in Libra DB account_type = infer_account_type_from_name(account_name) - user_id = extract_user_id_from_account_name(account_name) + # Prefer the full user_id stored in Beancount metadata (libra writes it + # when crud.get_or_create_user_account calls fava.add_account). Fall + # back to the name-derived 8-char prefix for accounts imported without + # metadata. This keeps user_id consistent with what the caller will + # query for, avoiding a churn cycle through the UNIQUE-constraint + # recovery path in crud.py. description = None + meta_user_id = None if "meta" in bc_account and isinstance(bc_account["meta"], dict): description = bc_account["meta"].get("description") + meta_user_id = bc_account["meta"].get("user_id") + + user_id = meta_user_id or extract_user_id_from_account_name(account_name) await create_account( CreateAccount( diff --git a/fava_client.py b/fava_client.py index 578f077..7719c38 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1626,9 +1626,12 @@ class FavaClient: logger.info(f"Account {account_name} already exists in {target_file}") return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 3: Find insertion point (after last Open directive AND its metadata) + # Step 3: Find insertion point (after last Open directive AND its metadata). + # If the file has no Open directives yet (e.g. the empty + # accounts/users.beancount seed), append at end of file + # so the seed header comments stay at the top. lines = source.split('\n') - insert_index = 0 + insert_index = None for i, line in enumerate(lines): if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: # Found an Open directive, now skip over any metadata lines @@ -1636,6 +1639,8 @@ class FavaClient: # Skip metadata lines (lines starting with whitespace) while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): insert_index += 1 + if insert_index is None: + insert_index = len(lines) # Step 4: Format Open directive as Beancount text currencies_str = ", ".join(currencies) @@ -1989,6 +1994,10 @@ class FavaClient: # Singleton instance (configured from settings) _fava_client: Optional[FavaClient] = None +# Set by init_fava_client; await for background tasks that must not run +# before the client exists (otherwise they raise "Fava client not initialized" +# during the first ~500ms of startup). +_fava_client_ready: asyncio.Event = asyncio.Event() def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0): @@ -2002,9 +2011,21 @@ def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0): """ global _fava_client _fava_client = FavaClient(fava_url, ledger_slug, timeout) + _fava_client_ready.set() logger.info(f"Fava client initialized: {fava_url}/{ledger_slug}") +async def wait_for_fava_client() -> FavaClient: + """Block until init_fava_client() has been called, then return the client. + + Use this from background tasks started in libra_start() — they otherwise + race the fire-and-forget _init_fava() coroutine and crash with + "Fava client not initialized" on first iteration. + """ + await _fava_client_ready.wait() + return get_fava_client() + + def get_fava_client() -> FavaClient: """ Get the configured Fava client. diff --git a/tasks.py b/tasks.py index f6f84cb..8ed5a33 100644 --- a/tasks.py +++ b/tasks.py @@ -134,8 +134,15 @@ async def wait_for_account_sync(): Background task that periodically syncs accounts from Beancount to Libra DB. Runs hourly to ensure Libra DB stays in sync with Beancount. + + Blocks on `wait_for_fava_client()` before the first iteration so we don't + race the fire-and-forget `_init_fava()` started in `libra_start()` and + fail the first sync with "Fava client not initialized". """ + from .fava_client import wait_for_fava_client + logger.info("[LIBRA] Account sync background task started") + await wait_for_fava_client() while True: try: