Caching and recalculation¶
A Frame is a record of one calculation run, not a reactive spreadsheet. The first lookup stores a success or ordinary failure snapshot. Recalculation is explicit, atomic, and limited to the selected definition.
What you will learn¶
- how lazy value snapshots work;
- how concurrent callers share one in-flight calculation;
- why changing an input does not invalidate dependants;
- when a new Frame is clearer than targeted recalculation.
Share one calculation across concurrent callers¶
Two callers request the same definition before it finishes. The Frame creates one owner task, shields it from individual waiter cancellation, and publishes one result for later reads.
import asyncio
from lclang.lang import define_frame, define_module
async def main() -> None:
calls = 0
release = asyncio.Event()
async def load_price() -> int:
nonlocal calls
calls += 1
await release.wait()
return 40
module = define_module(
"quote",
{"price": "load_price()", "total": "price * quantity"},
)
async with define_frame(
module,
preset={"load_price": load_price, "quantity": 3},
) as frame:
first = asyncio.create_task(frame.get("total"))
second = asyncio.create_task(frame.get("total"))
await asyncio.sleep(0)
release.set()
assert await asyncio.gather(first, second) == [120, 120]
assert await frame.get("total") == 120
assert calls == 1
asyncio.run(main())
Both total requests converge on the same in-flight price definition. Once
released, load_price returns 40; quantity scales it to 120, and the
Frame publishes that snapshot to both waiters. The later read uses the same
snapshot, which is why the provider counter remains 1.
Failure snapshots behave the same way for ordinary exceptions: the next read raises the stored structured failure rather than repeating host work.
Refresh only what you name¶
mixin updates local host values. Existing cached definitions remain exactly
as they were until explicitly recalculated.
import asyncio
from lclang.lang import define_frame, define_module
async def main() -> None:
module = define_module(
"invoice",
{
"subtotal": "unit_price * quantity",
"total": "subtotal + shipping",
"label": 'f"USD {total:.2f}"',
},
)
async with define_frame(
module,
preset={"unit_price": 10, "quantity": 3, "shipping": 5},
) as frame:
assert await frame.get("label") == "USD 35.00"
frame.mixin({"unit_price": 20})
assert await frame.recalculate("subtotal") == 60
assert await frame.get("total") == 35
assert await frame.get("label") == "USD 35.00"
assert await frame.recalculate("total") == 65
assert await frame.recalculate("label") == "USD 65.00"
asyncio.run(main())
The original chain computes 10 * 3 + 5 = 35. Mixing in 20 changes only the
host input. Recalculating subtotal produces 60, but the already cached
total and label remain 35. Refreshing those definitions in dependency
order finally produces and formats 65.
This rule is intentionally strong: recalculation never silently invalidates dependants. The application chooses the refresh order and therefore can explain the resulting state.
New Frame or recalculation?¶
Create a new Frame when inputs represent a new request, tenant, scenario, or
complete policy run. Use mixin plus recalculate when preserving other
snapshots is intentional—for example, refreshing one market quote while keeping
the rest of an audit snapshot fixed.
Recalculation publishes atomically. Ordinary reads keep seeing the old snapshot until the refresh succeeds or commits a new failure. Concurrent recalculation callers share one refresh owner. Cancellation commits nothing and leaves the previous snapshot available.
Previous: Async Python integration | Next: Errors and inspection | Return to the series introduction