Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fast-cache-contained-periods.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`delete_arrays`, `set_input` and spiral-cache purging now drop every contained period from the simulation's fast cache (every period for ETERNITY variables), as holder storage does, so `calculate` no longer returns values storage has deleted or replaced.
60 changes: 44 additions & 16 deletions policyengine_core/simulations/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,9 +1007,43 @@ def purge_cache_of_invalid_values(self) -> None:
holder = self.get_holder(_name)
holder.delete_arrays(_period)
if _fast_cache is not None:
_fast_cache.pop((_name, _period), None)
self._evict_fast_cache(_name, _period)
self.invalidated_caches = set()

def _evict_fast_cache(self, variable_name: str, period: Period = None) -> None:
"""Drop the ``_fast_cache`` entries a storage write or delete at ``period`` makes stale.

Holder storage deletes (and ``set_input`` handlers write) every period
that ``period`` contains: deleting ``"2017"`` also deletes
``"2017-01"`` to ``"2017-12"``, and an ETERNITY variable keeps one
value whatever period it is asked for. The fast cache is keyed by the
period ``calculate`` was asked for, so this drops each of the
variable's entries whose period ``period`` contains, and all of them
when ``period`` is ``None`` or the variable is defined for ETERNITY.
Popping only ``(variable_name, period)`` left ``calculate`` returning
values for contained periods that storage no longer held.
"""
_fast_cache = getattr(self, "_fast_cache", None)
if not _fast_cache:
return
if period is not None:
period = periods.period(period)
variable = self.tax_benefit_system.get_variable(variable_name)
if getattr(variable, "definition_period", None) == periods.ETERNITY:
period = None
stale_keys = [
key
for key in _fast_cache
if key[0] == variable_name
and (
period is None
or not isinstance(key[1], Period)
or period.contains(key[1])
)
]
for key in stale_keys:
del _fast_cache[key]

def calculate_add(
self,
variable_name: str,
Expand Down Expand Up @@ -1356,7 +1390,9 @@ def delete_arrays(self, variable: str, period: Period = None) -> None:
names and the parent simulation's holder storage remain unchanged.

:param variable: the variable whose cached values should be deleted
:param period: the period to delete, or all periods when omitted
:param period: the period to delete, with every period it contains
("2018" also deletes "2018-05"; any period deletes an ETERNITY
variable's value), or all periods when omitted

Example:

Expand All @@ -1381,17 +1417,9 @@ def delete_arrays(self, variable: str, period: Period = None) -> None:
holder = self.get_holder(variable)
for branch_name in self._get_visible_branch_names():
holder.delete_arrays(period, branch_name)
_fast_cache = getattr(self, "_fast_cache", None)
if period is None:
if _fast_cache is not None:
self._fast_cache = {
k: v for k, v in _fast_cache.items() if k[0] != variable
}
else:
if not isinstance(period, Period):
period = periods.period(period)
if _fast_cache is not None:
_fast_cache.pop((variable, period), None)
# Same periods as the holder deletes: deleting "2017" also drops a
# cached "2017-02".
self._evict_fast_cache(variable, period)

def get_known_periods(self, variable: str) -> List[Period]:
"""
Expand Down Expand Up @@ -1436,9 +1464,9 @@ def set_input(self, variable_name: str, period: Period, value: ArrayLike) -> Non
if (variable.end is not None) and (period.start.date > variable.end):
return
self.get_holder(variable_name).set_input(period, value, self.branch_name)
_fast_cache = getattr(self, "_fast_cache", None)
if _fast_cache is not None:
_fast_cache.pop((variable_name, period), None)
# A ``set_input`` handler may store every period ``period`` contains
# (an annual input to a monthly variable stores twelve months).
self._evict_fast_cache(variable_name, period)

def get_variable_population(self, variable_name: str) -> Population:
variable = self.tax_benefit_system.get_variable(
Expand Down
133 changes: 133 additions & 0 deletions tests/core/test_fast_cache_contained_periods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Deleting or setting a period drops every period it contains from the fast cache.

Holder storage deletes each period the requested one contains (deleting
"2012" also deletes "2012-02"), and an ETERNITY variable keeps one value for
every period. ``Simulation._fast_cache`` used to drop only the exact
``(variable, period)`` key, so ``calculate`` kept returning values storage no
longer held. The property versions of these tests are in
``test_fast_cache_contained_periods_property.py``.
"""

from __future__ import annotations

import numpy as np

from policyengine_core import periods
from tests.fixtures.fast_cache_contained_periods import build_simulation


def test_deleting_a_year_drops_cached_months():
"""The carry-over review's repro: a deleted month must not come back from the cache."""
simulation = build_simulation()
simulation.set_input("monthly_input", "2012-01", [100])
np.testing.assert_array_equal(
simulation.calculate("monthly_input", "2012-02"), [100]
)

simulation.delete_arrays("monthly_input", "2012")

assert simulation.get_array("monthly_input", "2012-01") is None
assert simulation.get_array("monthly_input", "2012-02") is None
simulation.set_input("monthly_input", "2012-01", [200])
fresh = build_simulation()
fresh.set_input("monthly_input", "2012-01", [200])
np.testing.assert_array_equal(
simulation.calculate("monthly_input", "2012-02"),
fresh.calculate("monthly_input", "2012-02"),
)
np.testing.assert_array_equal(
simulation.calculate("monthly_input", "2012-02"), [200]
)


def test_deleting_a_year_keeps_cached_values_it_does_not_contain():
"""Only what storage deletes leaves the fast cache."""
simulation = build_simulation()
simulation.set_input("monthly_input", "2012-01", [100])
february = simulation.calculate("monthly_input", "2012-02")
january_next_year = simulation.calculate("monthly_input", "2013-01")
other_variable = simulation.calculate("monthly_formula", "2012-02")

simulation.delete_arrays("monthly_input", "2012")

cache = simulation._fast_cache
assert ("monthly_input", periods.period("2012-02")) not in cache
assert cache[("monthly_input", periods.period("2013-01"))] is january_next_year
assert cache[("monthly_formula", periods.period("2012-02"))] is other_variable
assert february is not None


def test_deleting_a_month_keeps_the_rest_of_the_year():
simulation = build_simulation()
simulation.set_input("monthly_input", "2012-01", [100])
simulation.calculate("monthly_input", "2012-02")
march = simulation.calculate("monthly_input", "2012-03")

simulation.delete_arrays("monthly_input", "2012-02")

assert ("monthly_input", periods.period("2012-02")) not in simulation._fast_cache
assert simulation._fast_cache[("monthly_input", periods.period("2012-03"))] is march


def test_deleting_an_eternity_variable_at_any_period_drops_every_cached_period():
"""Storage keeps one ETERNITY value, so deleting it at "2012" also clears "2013"."""
simulation = build_simulation(carry_over=False)
simulation.set_input("eternal_input", "2012", [5])
np.testing.assert_array_equal(simulation.calculate("eternal_formula", "2013"), [10])

simulation.delete_arrays("eternal_formula", "2012")
simulation.set_input("eternal_input", "2012", [7])

assert simulation.get_array("eternal_formula", "2013") is None
np.testing.assert_array_equal(simulation.calculate("eternal_formula", "2013"), [14])


def test_setting_an_eternity_input_at_one_period_drops_it_at_every_period():
simulation = build_simulation(carry_over=False)
np.testing.assert_array_equal(simulation.calculate("eternal_input", "2012"), [0])

simulation.set_input("eternal_input", "2013", [5])

np.testing.assert_array_equal(simulation.get_array("eternal_input", "2012"), [5])
np.testing.assert_array_equal(simulation.calculate("eternal_input", "2012"), [5])


def test_setting_a_year_split_over_months_drops_cached_months():
"""A ``set_input`` handler stores every month of an annual input.

A variable the cache blacklist keeps out of storage still has its
calculated months in the fast cache, so those must go too.
"""
simulation = build_simulation(carry_over=False)
simulation.opt_out_cache = True
simulation.tax_benefit_system.cache_blacklist = {"monthly_input_split"}
np.testing.assert_array_equal(
simulation.calculate("monthly_input_split", "2012-02"), [0]
)

simulation.set_input("monthly_input_split", "2012", [1200])

np.testing.assert_array_equal(
simulation.get_array("monthly_input_split", "2012-02"), [100]
)
np.testing.assert_array_equal(
simulation.calculate("monthly_input_split", "2012-02"), [100]
)


def test_purging_an_invalidated_year_drops_cached_months():
"""``purge_cache_of_invalid_values`` deletes like ``delete_arrays`` does."""
simulation = build_simulation(carry_over=False)
simulation.set_input("monthly_input", "2012-02", [10])
np.testing.assert_array_equal(
simulation.calculate("monthly_formula", "2012-02"), [11]
)

simulation.invalidated_caches.add(("monthly_formula", periods.period("2012")))
simulation.purge_cache_of_invalid_values()
simulation.set_input("monthly_input", "2012-02", [20])

assert simulation.get_array("monthly_formula", "2012-02") is None
np.testing.assert_array_equal(
simulation.calculate("monthly_formula", "2012-02"), [21]
)
Loading
Loading