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
This commit is contained in:
Padreug 2026-06-06 19:36:39 +02:00
commit 09a5d6ed55
3 changed files with 40 additions and 3 deletions

View file

@ -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.