Add concurrency protection for Fava/Beancount ledger writes
This commit addresses critical race conditions when multiple requests try to write to the ledger file simultaneously. Changes: - Add global asyncio.Lock to FavaClient to serialize all write operations - Add per-user locks for finer-grained concurrency control - Wrap add_entry(), update_entry_source(), delete_entry() with write lock - Add retry logic with exponential backoff to add_account() for checksum conflicts - Add new add_entry_idempotent() method to prevent duplicate entries - Add ChecksumConflictError exception for conflict handling - Update on_invoice_paid() to use per-user locking and idempotent entry creation Fixes #4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e403ec223d
commit
b5c36504fb
2 changed files with 397 additions and 245 deletions
234
tasks.py
234
tasks.py
|
|
@ -187,6 +187,12 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
This function is called automatically when any invoice on the Castle wallet
|
||||
is paid. It checks if the invoice is a Castle payment and records it in
|
||||
Beancount via Fava.
|
||||
|
||||
Concurrency Protection:
|
||||
- Uses per-user locking to prevent race conditions when multiple payments
|
||||
for the same user are processed simultaneously
|
||||
- Uses idempotent entry creation to prevent duplicate entries even if
|
||||
the same payment is processed multiple times
|
||||
"""
|
||||
# Only process Castle-specific payments
|
||||
if not payment.extra or payment.extra.get("tag") != "castle":
|
||||
|
|
@ -197,134 +203,120 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
|
||||
return
|
||||
|
||||
# Check if payment already recorded (idempotency)
|
||||
# Query Fava for existing entry with this payment hash link
|
||||
from .fava_client import get_fava_client
|
||||
import httpx
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
try:
|
||||
# Check if payment already recorded by fetching recent entries
|
||||
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
|
||||
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
|
||||
link_to_find = f"ln-{payment.payment_hash[:16]}"
|
||||
# Use idempotency key based on payment hash - this ensures duplicate
|
||||
# processing of the same payment won't create duplicate entries
|
||||
idempotency_key = f"ln-{payment.payment_hash[:16]}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Get recent entries from Fava's journal endpoint
|
||||
response = await client.get(
|
||||
f"{fava.base_url}/api/journal",
|
||||
params={"time": ""} # Get all entries
|
||||
# Acquire per-user lock to serialize processing for this user
|
||||
# This prevents race conditions when a user has multiple payments being processed
|
||||
user_lock = fava.get_user_lock(user_id)
|
||||
|
||||
async with user_lock:
|
||||
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
||||
|
||||
try:
|
||||
from decimal import Decimal
|
||||
from .crud import get_account_by_name, get_or_create_user_account
|
||||
from .models import AccountType
|
||||
from .beancount_format import format_net_settlement_entry
|
||||
|
||||
# Convert amount from millisatoshis to satoshis
|
||||
amount_sats = payment.amount // 1000
|
||||
|
||||
# Extract fiat metadata from invoice (if present)
|
||||
fiat_currency = None
|
||||
fiat_amount = None
|
||||
if payment.extra:
|
||||
fiat_currency = payment.extra.get("fiat_currency")
|
||||
fiat_amount_str = payment.extra.get("fiat_amount")
|
||||
if fiat_amount_str:
|
||||
fiat_amount = Decimal(str(fiat_amount_str))
|
||||
|
||||
if not fiat_currency or not fiat_amount:
|
||||
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
|
||||
return
|
||||
|
||||
# Get user's current balance to determine receivables and payables
|
||||
balance = await fava.get_user_balance(user_id)
|
||||
fiat_balances = balance.get("fiat_balances", {})
|
||||
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
||||
|
||||
# Determine receivables and payables based on balance
|
||||
# Positive balance = user owes castle (receivable)
|
||||
# Negative balance = castle owes user (payable)
|
||||
if total_fiat_balance > 0:
|
||||
# User owes castle
|
||||
total_receivable = total_fiat_balance
|
||||
total_payable = Decimal(0)
|
||||
else:
|
||||
# Castle owes user
|
||||
total_receivable = Decimal(0)
|
||||
total_payable = abs(total_fiat_balance)
|
||||
|
||||
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
|
||||
|
||||
# Get account names
|
||||
user_receivable = await get_or_create_user_account(
|
||||
user_id, AccountType.ASSET, "Accounts Receivable"
|
||||
)
|
||||
user_payable = await get_or_create_user_account(
|
||||
user_id, AccountType.LIABILITY, "Accounts Payable"
|
||||
)
|
||||
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
||||
if not lightning_account:
|
||||
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||
return
|
||||
|
||||
# Query for unsettled entries to link this settlement back to them
|
||||
# Net settlement can settle both expenses and receivables
|
||||
settled_links = []
|
||||
try:
|
||||
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
|
||||
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
|
||||
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
|
||||
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
|
||||
# Continue without links - settlement will still be recorded
|
||||
|
||||
# Format as net settlement transaction
|
||||
entry = format_net_settlement_entry(
|
||||
user_id=user_id,
|
||||
payment_account=lightning_account.name,
|
||||
receivable_account=user_receivable.name,
|
||||
payable_account=user_payable.name,
|
||||
amount_sats=amount_sats,
|
||||
net_fiat_amount=fiat_amount,
|
||||
total_receivable_fiat=total_receivable,
|
||||
total_payable_fiat=total_payable,
|
||||
fiat_currency=fiat_currency,
|
||||
description=f"Lightning payment settlement from user {user_id[:8]}",
|
||||
entry_date=datetime.now().date(),
|
||||
payment_hash=payment.payment_hash,
|
||||
reference=payment.payment_hash,
|
||||
settled_entry_links=settled_links if settled_links else None
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
entries = data.get('entries', [])
|
||||
# Submit to Fava using idempotent method to prevent duplicates
|
||||
# The idempotency key is based on the payment hash, so even if this
|
||||
# function is called multiple times for the same payment, only one
|
||||
# entry will be created
|
||||
result = await fava.add_entry_idempotent(entry, idempotency_key)
|
||||
|
||||
# Check if any entry has our payment link
|
||||
for entry in entries:
|
||||
entry_links = entry.get('links', [])
|
||||
if link_to_find in entry_links:
|
||||
logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping")
|
||||
return
|
||||
if result.get("existing"):
|
||||
logger.info(
|
||||
f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully recorded payment {payment.payment_hash} to Fava: "
|
||||
f"{result.get('data', 'Unknown')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check Fava for duplicate payment: {e}")
|
||||
# Continue anyway - Fava/Beancount will catch duplicate if it exists
|
||||
|
||||
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
||||
|
||||
try:
|
||||
from decimal import Decimal
|
||||
from .crud import get_account_by_name, get_or_create_user_account
|
||||
from .models import AccountType
|
||||
from .beancount_format import format_net_settlement_entry
|
||||
|
||||
# Convert amount from millisatoshis to satoshis
|
||||
amount_sats = payment.amount // 1000
|
||||
|
||||
# Extract fiat metadata from invoice (if present)
|
||||
fiat_currency = None
|
||||
fiat_amount = None
|
||||
if payment.extra:
|
||||
fiat_currency = payment.extra.get("fiat_currency")
|
||||
fiat_amount_str = payment.extra.get("fiat_amount")
|
||||
if fiat_amount_str:
|
||||
fiat_amount = Decimal(str(fiat_amount_str))
|
||||
|
||||
if not fiat_currency or not fiat_amount:
|
||||
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
|
||||
return
|
||||
|
||||
# Get user's current balance to determine receivables and payables
|
||||
balance = await fava.get_user_balance(user_id)
|
||||
fiat_balances = balance.get("fiat_balances", {})
|
||||
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
||||
|
||||
# Determine receivables and payables based on balance
|
||||
# Positive balance = user owes castle (receivable)
|
||||
# Negative balance = castle owes user (payable)
|
||||
if total_fiat_balance > 0:
|
||||
# User owes castle
|
||||
total_receivable = total_fiat_balance
|
||||
total_payable = Decimal(0)
|
||||
else:
|
||||
# Castle owes user
|
||||
total_receivable = Decimal(0)
|
||||
total_payable = abs(total_fiat_balance)
|
||||
|
||||
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
|
||||
|
||||
# Get account names
|
||||
user_receivable = await get_or_create_user_account(
|
||||
user_id, AccountType.ASSET, "Accounts Receivable"
|
||||
)
|
||||
user_payable = await get_or_create_user_account(
|
||||
user_id, AccountType.LIABILITY, "Accounts Payable"
|
||||
)
|
||||
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
||||
if not lightning_account:
|
||||
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||
return
|
||||
|
||||
# Query for unsettled entries to link this settlement back to them
|
||||
# Net settlement can settle both expenses and receivables
|
||||
settled_links = []
|
||||
try:
|
||||
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
|
||||
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
|
||||
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
|
||||
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
|
||||
# Continue without links - settlement will still be recorded
|
||||
|
||||
# Format as net settlement transaction
|
||||
entry = format_net_settlement_entry(
|
||||
user_id=user_id,
|
||||
payment_account=lightning_account.name,
|
||||
receivable_account=user_receivable.name,
|
||||
payable_account=user_payable.name,
|
||||
amount_sats=amount_sats,
|
||||
net_fiat_amount=fiat_amount,
|
||||
total_receivable_fiat=total_receivable,
|
||||
total_payable_fiat=total_payable,
|
||||
fiat_currency=fiat_currency,
|
||||
description=f"Lightning payment settlement from user {user_id[:8]}",
|
||||
entry_date=datetime.now().date(),
|
||||
payment_hash=payment.payment_hash,
|
||||
reference=payment.payment_hash,
|
||||
settled_entry_links=settled_links if settled_links else None
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
result = await fava.add_entry(entry)
|
||||
|
||||
logger.info(
|
||||
f"Successfully recorded payment {payment.payment_hash} to Fava: "
|
||||
f"{result.get('data', 'Unknown')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
|
||||
raise
|
||||
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
|
||||
raise
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue