mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-25 19:11:44 +00:00
usage_projection/usage_breakdown re-aggregated every validated final row on every display read (~120-150ms of CPU per /api/state poll on a grown ledger) even though the PR-140 rows memo already made the rows themselves warm. The renders are now cached inside the memo, keyed by every input that shapes the output (function, root/task filters, resolved limit, integrity bit, include_roots), so a warm hit does zero full replays AND zero re-aggregation: - _LedgerRowsMemo gains a fingerprint-keyed `renders` dict, cleared on refold and on every non-empty advance (never TTL), plus a `generation` counter: a render computed outside the lock is published only when the memo object and generation are unchanged since the rows were read, so a concurrent append between read and publish returns the stale render to that caller without caching it (clear-then-publish race guard). - The non-resumable crash-tail fingerprint (st_ino == -2) is never cached — every read of a torn tail stays a full replay; _memoized_final_rows now transports (rows, cacheable, memo, generation). - TOTAL_BUDGET resolves BEFORE the key is built; the quarantine stat happens after the row read (which owns quarantine) and joins the key. - Renders are served and stored as deep copies: callers mutate nested buckets in place, and the cached object is shared between requests. - The cache seam stays inside the usage_projection/usage_breakdown bodies, so the 10+ monkeypatch-by-name pins and every existing call site inherit it. - usage_projection(include_roots=True) is a new keyword: the default keeps the full contract; gateway/state.py passes include_roots=False because /api/state serializes named scalars only, so by_root was built per poll for zero readers. The slim projection still carries limit_usd and remaining_known_usd (the two fields budget_remaining consumes); the by_root loop itself went from O(N x roots) list scans to one-pass dict grouping. - The memo + render-cache layer moves to ouroboros/_usage_rows_memo.py (usage_accounting sat at 1565 of the 1600-line module gate; same extraction precedent as _usage_rows.py). It resolves _locked/_read_records_locked back through the usage_accounting namespace at call time, so historical monkeypatch sites keep governing display reads; usage_ledger.py is untouched and stays cache-ignorant. - New tests pin: warm hit = zero replays + zero re-aggregation, append and rotation invalidation, crash-tail never cached, include_roots=False omits only by_root while /api/state stays field-identical, nested-bucket deep-copy isolation, and the concurrent-append publish guard. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""Fingerprint-keyed render cache inside the ledger rows memo (perf sprint P1).
|
|
|
|
``usage_projection``/``usage_breakdown`` cache their FINISHED renders inside
|
|
``_LedgerRowsMemo.renders``, keyed by every input that shapes the output
|
|
(function, root/task filters, resolved limit, integrity bit, include_roots).
|
|
These tests pin the cache's safety properties: a warm hit does zero full
|
|
replays AND zero re-aggregation; append/rotation invalidate; a non-resumable
|
|
crash-tail is never cached; results are deep copies (nested-bucket mutation by
|
|
one caller cannot poison another's read); and a concurrent append between the
|
|
row read and the publish leaves the stale render uncached (clear-then-publish
|
|
race guard). Row-level memo equivalence stays pinned by
|
|
tests/test_usage_rows_memo.py; write paths by tests/test_usage_accounting.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import types
|
|
|
|
import pytest
|
|
from starlette.requests import Request
|
|
|
|
from ouroboros import usage_accounting as ua
|
|
|
|
|
|
@pytest.fixture
|
|
def data_root(tmp_path, monkeypatch):
|
|
root = tmp_path / "data"
|
|
monkeypatch.setenv("OUROBOROS_DATA_DIR", str(root))
|
|
monkeypatch.setenv("OUROBOROS_SETTINGS_PATH", str(root / "settings.json"))
|
|
monkeypatch.setenv("TOTAL_BUDGET", "100")
|
|
(root / "state").mkdir(parents=True)
|
|
return root
|
|
|
|
|
|
def _request(data_root, **overrides):
|
|
values = {
|
|
"model": "openai/gpt-5.2",
|
|
"provider": "openai",
|
|
"reservation_usd": 0.05,
|
|
"drive_root": data_root,
|
|
"task_id": "child",
|
|
"root_task_id": "root",
|
|
"source": "test",
|
|
}
|
|
values.update(overrides)
|
|
return ua.AttemptRequest(**values)
|
|
|
|
|
|
def _memo_key(root) -> str:
|
|
return str(pathlib.Path(root).resolve(strict=False))
|
|
|
|
|
|
def _memo(root):
|
|
with ua._ROWS_MEMO_LOCK:
|
|
return ua._ROWS_MEMO.get(_memo_key(root))
|
|
|
|
|
|
def _clear_memo(root) -> None:
|
|
with ua._ROWS_MEMO_LOCK:
|
|
ua._ROWS_MEMO.pop(_memo_key(root), None)
|
|
|
|
|
|
def _seed_settled(data_root, *, cost_usd=0.25, task_id="child", root_task_id="root"):
|
|
reservation = ua.reserve_attempt(
|
|
_request(data_root, task_id=task_id, root_task_id=root_task_id)
|
|
)
|
|
ua.mark_dispatched(reservation)
|
|
ua.settle_attempt(
|
|
reservation,
|
|
{"prompt_tokens": 100, "completion_tokens": 20},
|
|
cost_usd=cost_usd,
|
|
cost_final=True,
|
|
)
|
|
|
|
|
|
_FOREIGN_ROW = {
|
|
"kind": "external_unmetered",
|
|
"attempt_id": "external-foreign-process",
|
|
"state": "settled",
|
|
"model": "", "provider": "external",
|
|
"cost_usd": None, "cost_final": False,
|
|
"reservation_upper_bound_usd": None,
|
|
"prompt_tokens": 3, "completion_tokens": 1,
|
|
"task_id": "foreign", "root_task_id": "foreign",
|
|
"parent_task_id": "", "category": "external", "source": "test",
|
|
}
|
|
|
|
|
|
def _install_counters(monkeypatch):
|
|
"""Count full ledger replays AND render re-aggregations (both namespaces
|
|
that matter: ``ua._read_records_locked`` feeds the memo; ``ua._summary`` /
|
|
``ua._breakdown_bucket`` are the aggregation entry points the render
|
|
closures resolve as module globals)."""
|
|
counts = {"full": 0, "summary": 0, "bucket": 0}
|
|
real_read = ua._read_records_locked
|
|
real_summary = ua._summary
|
|
real_bucket = ua._breakdown_bucket
|
|
|
|
def counting_read(root, *args, **kwargs):
|
|
counts["full"] += 1
|
|
return real_read(root, *args, **kwargs)
|
|
|
|
def counting_summary(rows):
|
|
counts["summary"] += 1
|
|
return real_summary(rows)
|
|
|
|
def counting_bucket(rows):
|
|
counts["bucket"] += 1
|
|
return real_bucket(rows)
|
|
|
|
monkeypatch.setattr(ua, "_read_records_locked", counting_read)
|
|
monkeypatch.setattr(ua, "_summary", counting_summary)
|
|
monkeypatch.setattr(ua, "_breakdown_bucket", counting_bucket)
|
|
return counts
|
|
|
|
|
|
def test_warm_hit_serves_cached_render_with_zero_replays_and_zero_recompute(
|
|
data_root, monkeypatch,
|
|
):
|
|
_seed_settled(data_root)
|
|
_clear_memo(data_root)
|
|
|
|
first_projection = ua.usage_projection(data_root)
|
|
first_breakdown = ua.usage_breakdown(data_root)
|
|
first_root = ua.usage_projection(data_root, root_task_id="root")
|
|
first_task = ua.usage_breakdown(data_root, task_id="child")
|
|
|
|
counts = _install_counters(monkeypatch)
|
|
for _ in range(3):
|
|
assert ua.usage_projection(data_root) == first_projection
|
|
assert ua.usage_breakdown(data_root) == first_breakdown
|
|
assert ua.usage_projection(data_root, root_task_id="root") == first_root
|
|
assert ua.usage_breakdown(data_root, task_id="child") == first_task
|
|
assert counts["full"] == 0, "warm hits must not replay the ledger"
|
|
assert counts["summary"] == 0 and counts["bucket"] == 0, (
|
|
"warm hits must serve the cached render, not re-aggregate"
|
|
)
|
|
|
|
|
|
def test_distinct_call_shapes_get_distinct_cache_entries(data_root, monkeypatch):
|
|
_seed_settled(data_root)
|
|
_clear_memo(data_root)
|
|
|
|
limited = ua.usage_projection(data_root, global_limit_usd=50.0)
|
|
env_default = ua.usage_projection(data_root) # TOTAL_BUDGET=100
|
|
assert limited["limit_usd"] == 50.0
|
|
assert env_default["limit_usd"] == 100.0
|
|
# A hot-reloaded env budget resolves BEFORE the key, so it never collides
|
|
# with the previously cached resolution.
|
|
monkeypatch.setenv("TOTAL_BUDGET", "60")
|
|
assert ua.usage_projection(data_root)["limit_usd"] == 60.0
|
|
|
|
|
|
def test_append_invalidates_cached_renders(data_root):
|
|
_seed_settled(data_root)
|
|
warm = ua.usage_projection(data_root)
|
|
assert warm["attempt_counts"] == {"settled": 1}
|
|
assert _memo(data_root).renders, "warm render must be cached"
|
|
|
|
ua.release_attempt(ua.reserve_attempt(_request(data_root, task_id="next")))
|
|
|
|
after = ua.usage_projection(data_root)
|
|
assert after["attempt_counts"] == {"settled": 1, "released": 1}
|
|
breakdown = ua.usage_breakdown(data_root)
|
|
assert breakdown["attempt_counts"] == {"settled": 1, "released": 1}
|
|
|
|
|
|
def test_rotation_invalidates_cached_renders(data_root):
|
|
_seed_settled(data_root)
|
|
assert ua.usage_projection(data_root)["attempt_counts"] == {"settled": 1}
|
|
|
|
ledger = data_root / ua.LEDGER_REL
|
|
replacement = data_root / "state" / "replacement.jsonl"
|
|
rows = [
|
|
{"seq": 1, "ts": "2026-08-08T00:00:00Z", "kind": "attempt", "attempt_id": "n1",
|
|
"state": "reserved", "model": "m", "provider": "openai",
|
|
"reservation_upper_bound_usd": 0.5, "task_id": "t", "root_task_id": "r"},
|
|
]
|
|
replacement.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
|
|
os.replace(replacement, ledger)
|
|
|
|
projection = ua.usage_projection(data_root)
|
|
assert projection["attempt_counts"] == {"reserved": 1}
|
|
assert projection["settled_usd"] == 0.0
|
|
|
|
|
|
def test_non_resumable_crash_tail_is_never_cached(data_root, monkeypatch):
|
|
_seed_settled(data_root)
|
|
ledger = data_root / ua.LEDGER_REL
|
|
raw = ledger.read_bytes()
|
|
assert raw.endswith(b"\n")
|
|
ledger.write_bytes(raw[:-1]) # valid JSON final row, no trailing newline
|
|
|
|
_clear_memo(data_root)
|
|
first = ua.usage_projection(data_root)
|
|
memo = _memo(data_root)
|
|
assert memo.resume.st_ino == -2
|
|
assert memo.renders == {}, "a non-resumable tail's render must not be cached"
|
|
|
|
counts = _install_counters(monkeypatch)
|
|
assert ua.usage_projection(data_root) == first
|
|
assert counts["summary"] > 0, "every crash-tail read must recompute"
|
|
assert _memo(data_root).renders == {}
|
|
|
|
|
|
def test_include_roots_false_omits_by_root_and_keeps_budget_fields(data_root):
|
|
_seed_settled(data_root, task_id="a", root_task_id="root-a")
|
|
_seed_settled(data_root, task_id="b", root_task_id="root-b", cost_usd=0.1)
|
|
|
|
full = ua.usage_projection(data_root, global_limit_usd=5.0)
|
|
slim = ua.usage_projection(data_root, global_limit_usd=5.0, include_roots=False)
|
|
|
|
assert "by_root" not in slim
|
|
assert set(full["by_root"]) == {"root-a", "root-b"}
|
|
assert slim["limit_usd"] == 5.0
|
|
assert slim["remaining_known_usd"] == full["remaining_known_usd"]
|
|
trimmed = dict(full)
|
|
trimmed.pop("by_root")
|
|
assert slim == trimmed, "include_roots=False must change nothing but by_root"
|
|
|
|
|
|
def test_one_pass_by_root_grouping_matches_row_filtering(data_root):
|
|
for lane in ("a", "b", "c"):
|
|
_seed_settled(
|
|
data_root, task_id=f"task-{lane}", root_task_id=f"root-{lane}",
|
|
cost_usd=0.05,
|
|
)
|
|
projection = ua.usage_projection(data_root)
|
|
assert sorted(projection["by_root"]) == ["root-a", "root-b", "root-c"]
|
|
for lane in ("a", "b", "c"):
|
|
expected = ua.usage_projection(data_root, root_task_id=f"root-{lane}")
|
|
bucket = projection["by_root"][f"root-{lane}"]
|
|
# The per-root projection resolves its own limit from row evidence; the
|
|
# grouped bucket must agree on every shared monetary field.
|
|
for field in (
|
|
"settled_usd", "confirmed_usd", "estimated_usd", "reserved_usd",
|
|
"unresolved_upper_bound_usd", "accounted_usd", "attempt_counts",
|
|
):
|
|
assert bucket[field] == expected[field]
|
|
|
|
|
|
def test_served_renders_are_deep_copies_nested_bucket_mutation_is_isolated(data_root):
|
|
_seed_settled(data_root)
|
|
|
|
first = ua.usage_breakdown(data_root)
|
|
original = first["by_model"]["openai/gpt-5.2"]["settled_usd"]
|
|
first["by_model"]["openai/gpt-5.2"]["settled_usd"] = 999.0
|
|
second = ua.usage_breakdown(data_root)
|
|
assert second["by_model"]["openai/gpt-5.2"]["settled_usd"] == original
|
|
|
|
projection = ua.usage_projection(data_root)
|
|
projection["by_root"]["root"]["settled_usd"] = 999.0
|
|
assert ua.usage_projection(data_root)["by_root"]["root"]["settled_usd"] == original
|
|
|
|
|
|
def test_concurrent_append_between_row_read_and_publish_is_not_cached(
|
|
data_root, monkeypatch,
|
|
):
|
|
"""GPT#1 race: rows are read under the lock, the render is computed outside
|
|
it. If another writer appends (and another reader advances the memo) in
|
|
that window, publishing the stale render would serve pre-append data until
|
|
the next invalidation. The generation guard must return the stale render to
|
|
THIS caller only, without caching it."""
|
|
_seed_settled(data_root)
|
|
_clear_memo(data_root)
|
|
|
|
real_summary = ua._summary
|
|
real_read = ua._read_records_locked
|
|
fired = {}
|
|
|
|
def racing_summary(rows):
|
|
if not fired:
|
|
fired["x"] = True
|
|
records = real_read(data_root)
|
|
ua._append_rows_locked(data_root, records, [dict(_FOREIGN_ROW)])
|
|
# A concurrent reader advances the memo (generation bump + clear).
|
|
ua._memoized_final_rows(data_root)
|
|
return real_summary(rows)
|
|
|
|
monkeypatch.setattr(ua, "_summary", racing_summary)
|
|
stale = ua.usage_projection(data_root, include_roots=False)
|
|
monkeypatch.setattr(ua, "_summary", real_summary)
|
|
|
|
# The caller got a consistent snapshot of the rows it read...
|
|
assert stale["attempt_counts"] == {"settled": 1}
|
|
# ...but the stale render was NOT published.
|
|
assert _memo(data_root).renders == {}
|
|
|
|
fresh = ua.usage_projection(data_root, include_roots=False)
|
|
assert fresh["attempt_counts"] == {"settled": 2}
|
|
assert _memo(data_root).renders, "the post-append render caches normally"
|
|
|
|
|
|
def test_api_state_uses_slim_projection_and_payload_is_field_identical(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
"""gateway/state.py passes include_roots=False; /api/state serializes only
|
|
named scalars, so the response must be byte-for-byte identical to one built
|
|
from the full projection."""
|
|
from ouroboros.gateway.state import api_state
|
|
from supervisor import queue, state, workers
|
|
|
|
root = tmp_path / "data"
|
|
(root / "state").mkdir(parents=True)
|
|
(root / "logs").mkdir(parents=True)
|
|
(root / "state" / "state.json").write_text(
|
|
json.dumps({"spent_usd": 0.0, "spent_calls": 0}), encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_DATA_DIR", str(root))
|
|
monkeypatch.setenv("OUROBOROS_SETTINGS_PATH", str(root / "settings.json"))
|
|
monkeypatch.setenv("TOTAL_BUDGET", "7.5")
|
|
ua.ensure_legacy_imported(root)
|
|
_seed_settled(root)
|
|
|
|
monkeypatch.setattr(state, "TOTAL_BUDGET_LIMIT", 7.5)
|
|
monkeypatch.setattr(state, "DRIVE_ROOT", str(root))
|
|
monkeypatch.setattr(state, "load_state", lambda: {"current_branch": "ouroboros"})
|
|
monkeypatch.setattr(workers, "WORKERS", {})
|
|
monkeypatch.setattr(workers, "PENDING", [])
|
|
monkeypatch.setattr(workers, "RUNNING", {})
|
|
monkeypatch.setattr(queue, "get_evolution_status_snapshot", lambda **_kwargs: {})
|
|
|
|
def _state_request():
|
|
return Request({
|
|
"type": "http", "method": "GET", "path": "/api/state", "headers": [],
|
|
"query_string": b"", "scheme": "http", "server": ("test", 80),
|
|
"client": ("test", 1),
|
|
"app": types.SimpleNamespace(
|
|
state=types.SimpleNamespace(drive_root=root, app_start=0.0),
|
|
),
|
|
})
|
|
|
|
seen_kwargs: list = []
|
|
real_projection = ua.usage_projection
|
|
|
|
def capturing(*args, **kwargs):
|
|
seen_kwargs.append(dict(kwargs))
|
|
return real_projection(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(ua, "usage_projection", capturing)
|
|
slim_response = asyncio.run(api_state(_state_request()))
|
|
assert slim_response.status_code == 200
|
|
assert any(k.get("include_roots") is False for k in seen_kwargs), (
|
|
"/api/state must request the slim projection"
|
|
)
|
|
|
|
def forcing_full(*args, **kwargs):
|
|
kwargs.pop("include_roots", None)
|
|
return real_projection(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(ua, "usage_projection", forcing_full)
|
|
full_response = asyncio.run(api_state(_state_request()))
|
|
assert full_response.status_code == 200
|
|
|
|
slim_payload = json.loads(slim_response.body)
|
|
full_payload = json.loads(full_response.body)
|
|
slim_payload.pop("uptime")
|
|
full_payload.pop("uptime")
|
|
assert slim_payload == full_payload
|