BQL balance query can read stale state immediately after add_entry #37

Open
opened 2026-06-06 21:22:10 +00:00 by padreug · 0 comments
Owner

What

Calling GET /libra/api/v1/balance (or any BQL-backed balance endpoint) in the same request flow as POST /libra/api/v1/entries/... can return a balance that doesn't yet include the just-posted entry. Fava lazily reloads the .beancount file and a balance call within ~50ms of an add_entry reads a stale view.

Reproduce

Most cleanly visible in tests, but reproducible by hand:

# Post an expense or receivable.
post_response = await client.post("/libra/api/v1/entries/receivable", ...)
# Read balance immediately — returns empty fiat_balances.
balance = await client.get("/libra/api/v1/balance", ...)

For the test harness, the workaround that consistently fixes it is to call GET /libra/api/v1/entries/user (which triggers a Fava /journal read and forces a reload) between the post and the balance read. The smoke-test approve flow benefits from the same effect — the approve handler reads the journal to find the pending entry, which warms Fava, and the subsequent balance call is clean.

Why this matters

Two categories of risk for production:

  1. Frontend race: an SPA that POSTs an entry and refreshes the balance in a single user action might paint a stale value.
  2. Automation / scripting: any external orchestration that posts then immediately reconciles against the balance can act on phantom-empty state.

Possible directions

a. Cache invalidation on write. Have FavaClient.add_entry flush whatever in-memory state the balance query reads from before returning. Cleanest in principle but requires understanding Fava's internal caching.

b. Explicit reload call after add_entry. Fava exposes /api/changed and probably an internal way to force a watcher cycle. Cheaper than (a) but may still have a small window.

c. Document and require client retry. Accept the eventual consistency window and have the balance endpoint accept a ?refresh=1 (or similar) that forces a Fava reload before computing. Pushes the cost to callers that care.

d. Server-side post-write barrier. Have the entry endpoints await one Fava read after the write before returning the response. Latency cost on every post but trivially correct.

(d) is probably the right default — entry posts are not hot-path, balance reads are. Adding ~30-50ms to each post to guarantee read-your-writes is worth it.

Workaround in place

Test harness fixtures pre-flight a list_user_entries call before any balance assertion. Production callers should be aware until a server-side fix lands.

Scope

  • fava_client.py: add_entry (and friends) — add a post-write sync step.
  • views_api.py: api_get_balance / api_get_all_balances — consider an explicit refresh entry point.
  • Document the current behaviour in CLAUDE.md regardless.
## What Calling `GET /libra/api/v1/balance` (or any BQL-backed balance endpoint) in the same request flow as `POST /libra/api/v1/entries/...` can return a balance that doesn't yet include the just-posted entry. Fava lazily reloads the `.beancount` file and a balance call within ~50ms of an `add_entry` reads a stale view. ## Reproduce Most cleanly visible in tests, but reproducible by hand: ```python # Post an expense or receivable. post_response = await client.post("/libra/api/v1/entries/receivable", ...) # Read balance immediately — returns empty fiat_balances. balance = await client.get("/libra/api/v1/balance", ...) ``` For the test harness, the workaround that consistently fixes it is to call `GET /libra/api/v1/entries/user` (which triggers a Fava `/journal` read and forces a reload) between the post and the balance read. The smoke-test approve flow benefits from the same effect — the approve handler reads the journal to find the pending entry, which warms Fava, and the subsequent balance call is clean. ## Why this matters Two categories of risk for production: 1. **Frontend race**: an SPA that POSTs an entry and refreshes the balance in a single user action might paint a stale value. 2. **Automation / scripting**: any external orchestration that posts then immediately reconciles against the balance can act on phantom-empty state. ## Possible directions a. **Cache invalidation on write.** Have `FavaClient.add_entry` flush whatever in-memory state the balance query reads from before returning. Cleanest in principle but requires understanding Fava's internal caching. b. **Explicit reload call** after `add_entry`. Fava exposes `/api/changed` and probably an internal way to force a watcher cycle. Cheaper than (a) but may still have a small window. c. **Document and require client retry.** Accept the eventual consistency window and have the balance endpoint accept a `?refresh=1` (or similar) that forces a Fava reload before computing. Pushes the cost to callers that care. d. **Server-side post-write barrier.** Have the entry endpoints await one Fava read after the write before returning the response. Latency cost on every post but trivially correct. (d) is probably the right default — entry posts are not hot-path, balance reads are. Adding ~30-50ms to each post to guarantee read-your-writes is worth it. ## Workaround in place Test harness fixtures pre-flight a `list_user_entries` call before any balance assertion. Production callers should be aware until a server-side fix lands. ## Scope - `fava_client.py: add_entry` (and friends) — add a post-write sync step. - `views_api.py: api_get_balance` / `api_get_all_balances` — consider an explicit refresh entry point. - Document the current behaviour in CLAUDE.md regardless.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/libra#37
No description provided.