Route account writes for the split Fava ledger layout #32

Merged
padreug merged 5 commits from feat/split-ledger into main 2026-06-06 18:03:26 +00:00
3 changed files with 40 additions and 3 deletions
Showing only changes of commit 09a5d6ed55 - Show all commits

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
Padreug 2026-06-06 19:36:39 +02:00

View file

@ -320,11 +320,20 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
# Create in Libra DB # Create in Libra DB
account_type = infer_account_type_from_name(account_name) 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 description = None
meta_user_id = None
if "meta" in bc_account and isinstance(bc_account["meta"], dict): if "meta" in bc_account and isinstance(bc_account["meta"], dict):
description = bc_account["meta"].get("description") 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( await create_account(
CreateAccount( CreateAccount(

View file

@ -1626,9 +1626,12 @@ class FavaClient:
logger.info(f"Account {account_name} already exists in {target_file}") logger.info(f"Account {account_name} already exists in {target_file}")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")} 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') lines = source.split('\n')
insert_index = 0 insert_index = None
for i, line in enumerate(lines): for i, line in enumerate(lines):
if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line:
# Found an Open directive, now skip over any metadata lines # Found an Open directive, now skip over any metadata lines
@ -1636,6 +1639,8 @@ class FavaClient:
# Skip metadata lines (lines starting with whitespace) # Skip metadata lines (lines starting with whitespace)
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1 insert_index += 1
if insert_index is None:
insert_index = len(lines)
# Step 4: Format Open directive as Beancount text # Step 4: Format Open directive as Beancount text
currencies_str = ", ".join(currencies) currencies_str = ", ".join(currencies)
@ -1989,6 +1994,10 @@ class FavaClient:
# Singleton instance (configured from settings) # Singleton instance (configured from settings)
_fava_client: Optional[FavaClient] = None _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): 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 global _fava_client
_fava_client = FavaClient(fava_url, ledger_slug, timeout) _fava_client = FavaClient(fava_url, ledger_slug, timeout)
_fava_client_ready.set()
logger.info(f"Fava client initialized: {fava_url}/{ledger_slug}") 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: def get_fava_client() -> FavaClient:
""" """
Get the configured Fava client. Get the configured Fava client.

View file

@ -134,8 +134,15 @@ async def wait_for_account_sync():
Background task that periodically syncs accounts from Beancount to Libra DB. Background task that periodically syncs accounts from Beancount to Libra DB.
Runs hourly to ensure Libra DB stays in sync with Beancount. 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") logger.info("[LIBRA] Account sync background task started")
await wait_for_fava_client()
while True: while True:
try: try: