fix(tracker): is_processed now checks expires_at, not just file existence 🐛
Some checks failed
create-release / build (push) Successful in 35s
CI / release (release) Failing after 1m31s

Previously is_processed() returned True for any record file that existed,
relying entirely on cleanup_expired() (called at __init__) to delete stale
files. Because cleanup runs at container startup — before Akahu transactions
are fetched — any record that expired exactly on that startup would be deleted
and then immediately missed, letting the duplicate through.

Fix: is_processed() reads the expires_at field from the JSON and returns False
if the record has expired, regardless of whether cleanup has run.

Also adds migrate_ttl.py script to retroactively extend expires_at on existing
records that were written under a shorter TTL, and bumps version to 0.1.3.
This commit is contained in:
2026-06-15 22:20:47 +12:00
parent 19735bebb7
commit 59c0bbde0c
4 changed files with 113 additions and 3 deletions

View File

@@ -215,6 +215,43 @@ class TestTransactionProcessor:
with pytest.raises(TransactionAlreadyProcessedError):
multi_decorated("multi_1", 100.00)
def test_is_processed_respects_expiry_without_cleanup(self, temp_dir):
"""Expired record must not block re-processing even if cleanup hasn't run.
This is the regression test for the bug where is_processed() only checked
file existence. cleanup_expired() runs at startup and deletes expired files,
but if the expired file is deleted AFTER a duplicate arrives in the same run
the duplicate would slip through. The fix: is_processed() reads expires_at
from the JSON directly.
"""
import json
processor = TransactionProcessor(storage_dir=temp_dir, ttl_days=1)
@processor.unique_transaction()
def pay(transaction_id, amount):
return "ok"
pay("ttl_check", 50.0)
record_path = os.path.join(temp_dir, "ttl_check.json")
assert os.path.exists(record_path)
# Manually backdate expires_at so the record is expired
with open(record_path) as f:
record = json.load(f)
record["expires_at"] = (datetime.now() - timedelta(seconds=1)).isoformat()
with open(record_path, "w") as f:
json.dump(record, f)
# is_processed must return False — the file still exists but it's expired
assert not processor.tracker.is_processed("ttl_check")
def test_is_processed_returns_true_for_valid_record(self, processor):
"""is_processed returns True for a record that exists and has not expired."""
processor.tracker.mark_processed("valid_tx")
assert processor.tracker.is_processed("valid_tx")
def test_non_string_transaction_id(self, processor):
"""Test handling of non-string transaction IDs."""