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
140
views_api.py
140
views_api.py
|
|
@ -2903,8 +2903,7 @@ async def api_approve_expense_entry(
|
|||
|
||||
This updates the transaction in the Beancount file via Fava API.
|
||||
"""
|
||||
import httpx
|
||||
from .fava_client import get_fava_client
|
||||
from .fava_client import ChecksumConflictError, get_fava_client
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
|
|
@ -2939,57 +2938,29 @@ async def api_approve_expense_entry(
|
|||
detail="Entry metadata missing filename or lineno"
|
||||
)
|
||||
|
||||
# 3. Get the source file from Fava
|
||||
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{fava.base_url}/source",
|
||||
params={"filename": filename}
|
||||
)
|
||||
response.raise_for_status()
|
||||
source_data = response.json()["data"]
|
||||
# 3. Flip the flag under FavaClient's write lock — the whole
|
||||
# read-modify-write is atomic against every other ledger writer.
|
||||
old_pattern = f"{date_str} !"
|
||||
|
||||
sha256sum = source_data["sha256sum"]
|
||||
source = source_data["source"]
|
||||
lines = source.split('\n')
|
||||
|
||||
# 4. Find and modify the entry at the specified line
|
||||
# Line numbers are 1-indexed, list is 0-indexed
|
||||
entry_line_idx = lineno - 1
|
||||
|
||||
if entry_line_idx >= len(lines):
|
||||
def _approve(line: str) -> str:
|
||||
if old_pattern not in line:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Line {lineno} not found in source file"
|
||||
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {line}"
|
||||
)
|
||||
return line.replace(old_pattern, f"{date_str} *", 1)
|
||||
|
||||
entry_line = lines[entry_line_idx]
|
||||
|
||||
# Check if the line contains the pending flag pattern
|
||||
old_pattern = f"{date_str} !"
|
||||
if old_pattern not in entry_line:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}"
|
||||
)
|
||||
|
||||
# Replace the flag
|
||||
new_pattern = f"{date_str} *"
|
||||
new_line = entry_line.replace(old_pattern, new_pattern, 1)
|
||||
lines[entry_line_idx] = new_line
|
||||
|
||||
# 5. Write back the modified source
|
||||
new_source = '\n'.join(lines)
|
||||
|
||||
update_response = await client.put(
|
||||
f"{fava.base_url}/source",
|
||||
json={
|
||||
"file_path": filename,
|
||||
"source": new_source,
|
||||
"sha256sum": sha256sum
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
try:
|
||||
await fava.transform_source_line(filename, lineno, _approve)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
|
||||
)
|
||||
except ChecksumConflictError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.CONFLICT,
|
||||
detail="Ledger changed concurrently; retry the approval",
|
||||
)
|
||||
update_response.raise_for_status()
|
||||
|
||||
logger.info(f"Entry {entry_id} approved (flag changed to *)")
|
||||
|
||||
|
|
@ -3012,8 +2983,7 @@ async def api_reject_expense_entry(
|
|||
Adds #voided tag for audit trail while keeping the '!' flag.
|
||||
Voided transactions are excluded from balances but preserved in the ledger.
|
||||
"""
|
||||
import httpx
|
||||
from .fava_client import get_fava_client
|
||||
from .fava_client import ChecksumConflictError, get_fava_client
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
|
|
@ -3048,50 +3018,26 @@ async def api_reject_expense_entry(
|
|||
detail="Entry metadata missing filename or lineno"
|
||||
)
|
||||
|
||||
# 3. Get the source file from Fava
|
||||
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{fava.base_url}/source",
|
||||
params={"filename": filename}
|
||||
# 3. Add the #voided tag under FavaClient's write lock — the whole
|
||||
# read-modify-write is atomic against every other ledger writer.
|
||||
def _void(line: str) -> str:
|
||||
if "#voided" in line:
|
||||
return line # already voided — no-op
|
||||
return line.rstrip() + ' #voided'
|
||||
|
||||
try:
|
||||
changed = await fava.transform_source_line(filename, lineno, _void)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
|
||||
)
|
||||
response.raise_for_status()
|
||||
source_data = response.json()["data"]
|
||||
|
||||
sha256sum = source_data["sha256sum"]
|
||||
source = source_data["source"]
|
||||
lines = source.split('\n')
|
||||
|
||||
# 4. Find and modify the entry at the specified line - add #voided tag
|
||||
entry_line_idx = lineno - 1
|
||||
|
||||
if entry_line_idx >= len(lines):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Line {lineno} not found in source file"
|
||||
)
|
||||
|
||||
entry_line = lines[entry_line_idx]
|
||||
|
||||
# Add #voided tag if not already present
|
||||
if "#voided" not in entry_line:
|
||||
# Add #voided tag to the transaction line
|
||||
new_line = entry_line.rstrip() + ' #voided'
|
||||
lines[entry_line_idx] = new_line
|
||||
|
||||
# 5. Write back the modified source
|
||||
new_source = '\n'.join(lines)
|
||||
|
||||
update_response = await client.put(
|
||||
f"{fava.base_url}/source",
|
||||
json={
|
||||
"file_path": filename,
|
||||
"source": new_source,
|
||||
"sha256sum": sha256sum
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
update_response.raise_for_status()
|
||||
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
|
||||
except ChecksumConflictError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.CONFLICT,
|
||||
detail="Ledger changed concurrently; retry the rejection",
|
||||
)
|
||||
if changed:
|
||||
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
|
||||
|
||||
return {
|
||||
"message": f"Entry {entry_id} rejected (marked as voided)",
|
||||
|
|
@ -3820,8 +3766,14 @@ async def api_admin_add_chart_account(
|
|||
"already_existed": True,
|
||||
}
|
||||
|
||||
# Mirror into libra DB so permissions / metadata layer sees it.
|
||||
synced = await sync_single_account_from_beancount(payload.name)
|
||||
# Mirror into libra DB so permissions / metadata layer sees it. We just
|
||||
# wrote the Open directive ourselves, so skip the verification
|
||||
# round-trip through Fava (libra-#53).
|
||||
synced = await sync_single_account_from_beancount(
|
||||
payload.name,
|
||||
description=payload.description,
|
||||
assume_exists=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue