fix(fava): serialize source mutations, share HTTP client, validate BQL input
Fava-client hardening cluster (CODE-REVIEW-2026-06 #7, #14, #15, #19 + libra-#23, libra-#53): - New FavaClient.transform_source_line does the whole read-checksum-modify-write under the global write lock and maps Fava 409/412 to ChecksumConflictError. The approve and reject endpoints used to do this dance with raw httpx and no lock — two concurrent mutations raced each other and every other ledger writer (libra-#23). They now route through the new method and translate conflicts to HTTP 409. - update_entry_source / delete_entry raise ChecksumConflictError on 409/412 instead of leaking raw HTTPStatusError. - One shared httpx.AsyncClient per FavaClient (12 per-call instantiations removed — no more TCP handshake per request); closed via libra_stop. Health probes keep their 2s timeout per-request. - Account names/patterns are validated against ^[A-Za-z0-9:_-]+$ before interpolation into BQL string literals. - The posting amount regexes are consolidated into module-level compiled patterns, all decimal-tolerant — the old integer-only SATS pattern silently dropped decimal-SATS postings (Fava's @@->@ normalisation emits them) from balances. - add-account no longer verifies its own write with a second serialized get_all_accounts round-trip (libra-#53): sync_single_account_from_beancount grows an assume_exists path. New test: concurrent approve+reject must both land (was lost-update/412 before the lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0c8acbe30
commit
4d63e08a69
5 changed files with 305 additions and 123 deletions
182
fava_client.py
182
fava_client.py
|
|
@ -20,7 +20,8 @@ See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
|
|||
import asyncio
|
||||
import re
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime
|
||||
from loguru import logger
|
||||
|
|
@ -44,6 +45,34 @@ def _infer_target_file(account_name: str) -> str:
|
|||
return "accounts/chart.beancount"
|
||||
|
||||
|
||||
# Posting amount-string patterns shared by the balance parsers. Fava's
|
||||
# @@ → @ normalisation can emit decimal SATS values, so every SATS group
|
||||
# must tolerate decimals (the old integer-only pattern silently dropped
|
||||
# those postings from balances).
|
||||
_TOTAL_PRICE_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?[\d.]+)\s+SATS$')
|
||||
_UNIT_PRICE_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$')
|
||||
_FIAT_AMOUNT_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})$')
|
||||
_SATS_AMOUNT_RE = re.compile(r'^(-?[\d.]+)\s+SATS')
|
||||
|
||||
|
||||
def _sats_to_int(value: str) -> int:
|
||||
"""Parse a (possibly decimal) SATS amount string to whole sats."""
|
||||
return int(Decimal(value))
|
||||
|
||||
|
||||
# Account names/patterns are interpolated into BQL string literals; restrict
|
||||
# them to Beancount account characters so caller-supplied input can't break
|
||||
# out of the quoted literal.
|
||||
_BQL_ACCOUNT_RE = re.compile(r'^[A-Za-z0-9:_-]+$')
|
||||
|
||||
|
||||
def _validate_bql_account(value: str) -> str:
|
||||
"""Validate a value bound for interpolation into a BQL string literal."""
|
||||
if not _BQL_ACCOUNT_RE.match(value):
|
||||
raise ValueError(f"Invalid account name for BQL query: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _escape_beancount_string(value: str) -> str:
|
||||
"""Escape a value for safe inclusion in a Beancount string literal.
|
||||
|
||||
|
|
@ -136,6 +165,27 @@ class FavaClient:
|
|||
self._main_dir_cache: Optional[str] = None
|
||||
self._main_dir_lock = asyncio.Lock()
|
||||
|
||||
# Shared HTTP client, created lazily on first use. One client
|
||||
# means one connection pool instead of a TCP handshake per call.
|
||||
self._http: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""Yield the shared HTTP client (lazily created).
|
||||
|
||||
Kept as a context manager so call sites read the same as the
|
||||
per-call clients they replace; the client itself is NOT closed on
|
||||
exit — call `aclose()` at extension shutdown.
|
||||
"""
|
||||
if self._http is None or self._http.is_closed:
|
||||
self._http = httpx.AsyncClient(timeout=self.timeout)
|
||||
yield self._http
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the shared HTTP client (extension shutdown)."""
|
||||
if self._http is not None and not self._http.is_closed:
|
||||
await self._http.aclose()
|
||||
|
||||
async def _resolve_target_file(self, target_file: str) -> str:
|
||||
"""
|
||||
Turn a relative include path into the absolute path fava expects.
|
||||
|
|
@ -160,7 +210,7 @@ class FavaClient:
|
|||
if self._main_dir_cache is None:
|
||||
async with self._main_dir_lock:
|
||||
if self._main_dir_cache is None:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(f"{self.base_url}/options")
|
||||
resp.raise_for_status()
|
||||
main_file = resp.json()["data"]["beancount_options"]["filename"]
|
||||
|
|
@ -236,7 +286,7 @@ class FavaClient:
|
|||
# Acquire global write lock to serialize ledger modifications
|
||||
async with self._write_lock:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/add_entries",
|
||||
json={"entries": [entry]},
|
||||
|
|
@ -350,10 +400,11 @@ class FavaClient:
|
|||
|
||||
# Use sum(weight) for SATS and sum(number) for fiat
|
||||
# Note: BQL doesn't support != operator, so use flag = '*' to exclude pending
|
||||
_validate_bql_account(account_name)
|
||||
query = f"SELECT sum(number), sum(weight) WHERE account = '{account_name}' AND flag = '*'"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/query",
|
||||
params={"query_string": query}
|
||||
|
|
@ -446,14 +497,14 @@ class FavaClient:
|
|||
import re
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||
total_price_match = _TOTAL_PRICE_RE.match(amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
unit_price_match = _UNIT_PRICE_RE.match(amount_str)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = Decimal(total_price_match.group(1))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
sats_amount = int(total_price_match.group(3))
|
||||
sats_amount = _sats_to_int(total_price_match.group(3))
|
||||
|
||||
if fiat_currency not in fiat_balances:
|
||||
fiat_balances[fiat_currency] = Decimal(0)
|
||||
|
|
@ -480,8 +531,8 @@ class FavaClient:
|
|||
accounts_dict[account_name]["sats"] += sats_amount
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
elif _FIAT_AMOUNT_RE.match(amount_str):
|
||||
fiat_match = _FIAT_AMOUNT_RE.match(amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
|
@ -502,9 +553,9 @@ class FavaClient:
|
|||
|
||||
else:
|
||||
# Old format: SATS with cost/price notation - extract SATS amount
|
||||
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
||||
sats_match = _SATS_AMOUNT_RE.match(amount_str)
|
||||
if sats_match:
|
||||
sats_amount = int(sats_match.group(1))
|
||||
sats_amount = _sats_to_int(sats_match.group(1))
|
||||
total_sats += sats_amount
|
||||
|
||||
# Track per account
|
||||
|
|
@ -603,14 +654,14 @@ class FavaClient:
|
|||
import re
|
||||
|
||||
# Try total price notation: "50.00 EUR @@ 50000 SATS"
|
||||
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
|
||||
total_price_match = _TOTAL_PRICE_RE.match(amount_str)
|
||||
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
|
||||
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
|
||||
unit_price_match = _UNIT_PRICE_RE.match(amount_str)
|
||||
|
||||
if total_price_match:
|
||||
fiat_amount = Decimal(total_price_match.group(1))
|
||||
fiat_currency = total_price_match.group(2)
|
||||
sats_amount = int(total_price_match.group(3))
|
||||
sats_amount = _sats_to_int(total_price_match.group(3))
|
||||
|
||||
if fiat_currency not in user_data[user_id]["fiat_balances"]:
|
||||
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
|
||||
|
|
@ -629,8 +680,8 @@ class FavaClient:
|
|||
user_data[user_id]["balance"] += sats_amount
|
||||
|
||||
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
|
||||
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
|
||||
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||
elif _FIAT_AMOUNT_RE.match(amount_str):
|
||||
fiat_match = _FIAT_AMOUNT_RE.match(amount_str)
|
||||
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||
fiat_amount = Decimal(fiat_match.group(1))
|
||||
fiat_currency = fiat_match.group(2)
|
||||
|
|
@ -648,9 +699,9 @@ class FavaClient:
|
|||
|
||||
else:
|
||||
# Old format: SATS with cost/price notation
|
||||
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
||||
sats_match = _SATS_AMOUNT_RE.match(amount_str)
|
||||
if sats_match:
|
||||
sats_amount = int(sats_match.group(1))
|
||||
sats_amount = _sats_to_int(sats_match.group(1))
|
||||
user_data[user_id]["balance"] += sats_amount
|
||||
|
||||
# Extract fiat from cost syntax or metadata (backward compatibility)
|
||||
|
|
@ -683,9 +734,12 @@ class FavaClient:
|
|||
True if Fava responds, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
async with self._client() as client:
|
||||
# Health probes stay fast regardless of the configured
|
||||
# request timeout.
|
||||
response = await client.get(
|
||||
f"{self.base_url}/changed"
|
||||
f"{self.base_url}/changed",
|
||||
timeout=2.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
|
|
@ -721,12 +775,13 @@ class FavaClient:
|
|||
"""
|
||||
# Build Beancount query
|
||||
if account_pattern:
|
||||
_validate_bql_account(account_pattern)
|
||||
query = f"SELECT * WHERE account ~ '{account_pattern}' ORDER BY date DESC LIMIT {limit}"
|
||||
else:
|
||||
query = f"SELECT * ORDER BY date DESC LIMIT {limit}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/query",
|
||||
params={"query_string": query}
|
||||
|
|
@ -807,7 +862,7 @@ class FavaClient:
|
|||
https://beancount.github.io/docs/beancount_query_language.html
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/query",
|
||||
params={"query_string": query_string}
|
||||
|
|
@ -1341,7 +1396,7 @@ class FavaClient:
|
|||
# (BQL's SELECT DISTINCT account only returns accounts with postings)
|
||||
account_names: set[str] = set()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
for endpoint in ("balance_sheet", "income_statement"):
|
||||
try:
|
||||
response = await client.get(f"{self.base_url}/{endpoint}")
|
||||
|
|
@ -1440,7 +1495,7 @@ class FavaClient:
|
|||
params["time"] = f"{cutoff_date.isoformat()} - {today.isoformat()}"
|
||||
logger.info(f"Querying journal for last {days} days (from {cutoff_date})")
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.get(f"{self.base_url}/journal", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
|
@ -1482,7 +1537,7 @@ class FavaClient:
|
|||
sha256sum = context["sha256sum"]
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/context",
|
||||
params={"entry_hash": entry_hash}
|
||||
|
|
@ -1529,7 +1584,7 @@ class FavaClient:
|
|||
# Acquire global write lock to serialize ledger modifications
|
||||
async with self._write_lock:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/source_slice",
|
||||
json={
|
||||
|
|
@ -1544,11 +1599,78 @@ class FavaClient:
|
|||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
|
||||
if e.response.status_code in (409, 412):
|
||||
raise ChecksumConflictError(
|
||||
f"Entry {entry_hash} changed concurrently"
|
||||
) from e
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Fava connection error: {e}")
|
||||
raise
|
||||
|
||||
async def transform_source_line(
|
||||
self,
|
||||
filename: str,
|
||||
lineno: int,
|
||||
transform: Callable[[str], str],
|
||||
) -> bool:
|
||||
"""Atomically read-modify-write one line of a ledger source file.
|
||||
|
||||
Holds the global write lock across the whole read-modify-write, so
|
||||
another writer can't slip in between the checksum read and the
|
||||
write (libra-#23: the approve/reject endpoints used to do this
|
||||
read-then-write with raw httpx and no lock).
|
||||
|
||||
The transform receives the current line and returns the new one;
|
||||
returning it unchanged skips the write.
|
||||
|
||||
Returns:
|
||||
True when the line was changed and written, False on a no-op.
|
||||
|
||||
Raises:
|
||||
ValueError: lineno is outside the file.
|
||||
ChecksumConflictError: an out-of-process writer changed the
|
||||
file between read and write (409/412 from Fava).
|
||||
"""
|
||||
async with self._write_lock:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/source",
|
||||
params={"filename": filename},
|
||||
)
|
||||
response.raise_for_status()
|
||||
source_data = response.json()["data"]
|
||||
sha256sum = source_data["sha256sum"]
|
||||
lines = source_data["source"].split("\n")
|
||||
|
||||
idx = lineno - 1
|
||||
if idx < 0 or idx >= len(lines):
|
||||
raise ValueError(f"Line {lineno} not found in {filename}")
|
||||
|
||||
new_line = transform(lines[idx])
|
||||
if new_line == lines[idx]:
|
||||
return False
|
||||
lines[idx] = new_line
|
||||
|
||||
try:
|
||||
update = await client.put(
|
||||
f"{self.base_url}/source",
|
||||
json={
|
||||
"file_path": filename,
|
||||
"source": "\n".join(lines),
|
||||
"sha256sum": sha256sum,
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
update.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in (409, 412):
|
||||
raise ChecksumConflictError(
|
||||
f"{filename} changed concurrently"
|
||||
) from e
|
||||
raise
|
||||
return True
|
||||
|
||||
async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
|
||||
"""
|
||||
Delete an entry from the Beancount file.
|
||||
|
|
@ -1571,7 +1693,7 @@ class FavaClient:
|
|||
# Acquire global write lock to serialize ledger modifications
|
||||
async with self._write_lock:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
response = await client.delete(
|
||||
f"{self.base_url}/source_slice",
|
||||
params={
|
||||
|
|
@ -1585,6 +1707,10 @@ class FavaClient:
|
|||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
|
||||
if e.response.status_code in (409, 412):
|
||||
raise ChecksumConflictError(
|
||||
f"Entry {entry_hash} changed concurrently"
|
||||
) from e
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Fava connection error: {e}")
|
||||
|
|
@ -1664,7 +1790,7 @@ class FavaClient:
|
|||
# Acquire global write lock to serialize ledger modifications
|
||||
async with self._write_lock:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with self._client() as client:
|
||||
# Step 1: Get current source file (fresh read on each attempt)
|
||||
response = await client.get(
|
||||
f"{self.base_url}/source",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue