|
13 | 13 | import contextvars |
14 | 14 | import logging |
15 | 15 | import os |
| 16 | +import sys |
16 | 17 | import threading |
17 | 18 | import time |
| 19 | +from enum import Enum |
18 | 20 | from pathlib import Path |
19 | 21 | from typing import Any, Optional |
20 | 22 |
|
@@ -797,3 +799,169 @@ def __exit__(self, *exc: object) -> None: |
797 | 799 | l1._lock = real_lock |
798 | 800 | assert acquisitions == 3 # 1 000-key batches: a large drain never holds every get/put off at once |
799 | 801 | assert l1.get("k2499") == (False, None) |
| 802 | + |
| 803 | + |
| 804 | +class _NS(str, Enum): |
| 805 | + USERS = "users" |
| 806 | + |
| 807 | + |
| 808 | +class _StrEnumNS(str, Enum): |
| 809 | + """enum.StrEnum's rendering (3.11+), spelled out so the test also runs on 3.10.""" |
| 810 | + |
| 811 | + USERS = "users" |
| 812 | + __str__ = str.__str__ |
| 813 | + __format__ = str.__format__ |
| 814 | + |
| 815 | + |
| 816 | +class _FormatsAs(str): |
| 817 | + """A str whose __format__ lies: f-strings render ``rendered``, "".join the real value.""" |
| 818 | + |
| 819 | + rendered = "ck:reg:users" |
| 820 | + |
| 821 | + def __format__(self, spec: str) -> str: |
| 822 | + return self.rendered |
| 823 | + |
| 824 | + |
| 825 | +class _LegacyFormat(_FormatsAs): |
| 826 | + rendered = "legacy" |
| 827 | + |
| 828 | + |
| 829 | +class _HidesCk(str): |
| 830 | + """A str that claims not to be "ck" and not to start with it.""" |
| 831 | + |
| 832 | + def __eq__(self, other: object) -> bool: |
| 833 | + return False |
| 834 | + |
| 835 | + __hash__ = str.__hash__ |
| 836 | + |
| 837 | + def startswith(self, *args: Any, **kwargs: Any) -> bool: # type: ignore[override] |
| 838 | + return False |
| 839 | + |
| 840 | + |
| 841 | +@pytest.mark.unit |
| 842 | +class TestNamespaceExactStr: |
| 843 | + """A str-subclass namespace keys and names its registry set by its underlying str (LAB-6197).""" |
| 844 | + |
| 845 | + def test_str_enum_registry_id_uses_value(self) -> None: |
| 846 | + backend = TrackingBackend() |
| 847 | + |
| 848 | + @cache(backend=backend, ttl=60, namespace=_NS.USERS, l1_enabled=False) |
| 849 | + def f(x: int) -> int: |
| 850 | + return x |
| 851 | + |
| 852 | + f(1) |
| 853 | + (rid,) = _registry_ids(backend) |
| 854 | + assert rid.startswith("ck:reg:users:") |
| 855 | + |
| 856 | + def test_str_enum_custom_key_uses_value(self) -> None: |
| 857 | + backend = TrackingBackend() |
| 858 | + |
| 859 | + @cache(backend=backend, ttl=60, namespace=_NS.USERS, key=lambda *a, **kw: "k", l1_enabled=False) |
| 860 | + def f(x: int) -> int: |
| 861 | + return x |
| 862 | + |
| 863 | + f(1) |
| 864 | + assert set(backend.store) == {"users:k"} |
| 865 | + (rid,) = _registry_ids(backend) |
| 866 | + assert rid.startswith("ck:reg:users:") |
| 867 | + |
| 868 | + def test_format_override_cannot_forge_registry_shape(self) -> None: |
| 869 | + backend = TrackingBackend() |
| 870 | + |
| 871 | + @cache(backend=backend, ttl=60, namespace=_FormatsAs("x"), key=lambda *a, **kw: "k", l1_enabled=False) |
| 872 | + def f(x: int) -> int: |
| 873 | + return x |
| 874 | + |
| 875 | + f(1) |
| 876 | + assert set(backend.store) == {"x:k"} |
| 877 | + (rid,) = _registry_ids(backend) |
| 878 | + assert rid.startswith("ck:reg:x:") |
| 879 | + |
| 880 | + @pytest.mark.parametrize("value", ["ck", "ck:reg"]) |
| 881 | + def test_eq_and_startswith_override_cannot_bypass_ck_reservation(self, value: str) -> None: |
| 882 | + ns = _HidesCk(value) |
| 883 | + assert not ns == "ck" and not ns.startswith("ck:") # the overrides the old check trusted |
| 884 | + with pytest.raises(ConfigurationError, match="reserved"): |
| 885 | + |
| 886 | + @cache(backend=TrackingBackend(), ttl=60, namespace=ns) |
| 887 | + def f(x: int) -> int: |
| 888 | + return x |
| 889 | + |
| 890 | + def test_drain_also_empties_pre_fix_registry_set(self) -> None: |
| 891 | + """Entries tracked under the pre-fix f-string registry id go on a no-args drain.""" |
| 892 | + backend = TrackingBackend() |
| 893 | + |
| 894 | + @cache(backend=backend, ttl=60, namespace=_LegacyFormat("x"), l1_enabled=False) |
| 895 | + def f(x: int) -> int: |
| 896 | + return x |
| 897 | + |
| 898 | + f(1) |
| 899 | + (rid,) = _registry_ids(backend) |
| 900 | + legacy_rid = "ck:reg:legacy:" + rid.rsplit(":", 1)[1] |
| 901 | + backend.store["pre-upgrade-key"] = b"x" # written and tracked by pre-fix code |
| 902 | + backend.sets[legacy_rid] = {"pre-upgrade-key"} |
| 903 | + |
| 904 | + f.invalidate_cache() |
| 905 | + assert backend.store == {} |
| 906 | + assert [r for r, _ in backend.drain_calls] == [rid, legacy_rid] |
| 907 | + |
| 908 | + def test_legacy_drain_failure_still_applies_primary_drain(self, caplog: pytest.LogCaptureFixture) -> None: |
| 909 | + """A failed legacy drain must not discard what the primary drain deleted: another |
| 910 | + wrapper's shared-L1 copy of a drained key is still evicted, and the old set is kept.""" |
| 911 | + |
| 912 | + class LegacyFails(TrackingBackend): |
| 913 | + def drain_tracked(self, registry_id: str, local_keys: Any) -> set[str]: |
| 914 | + if registry_id.startswith("ck:reg:legacy:"): |
| 915 | + raise BackendError("legacy drain failed") |
| 916 | + return super().drain_tracked(registry_id, local_keys) |
| 917 | + |
| 918 | + backend = LegacyFails() |
| 919 | + calls: list[int] = [] |
| 920 | + |
| 921 | + def f(x: int) -> int: |
| 922 | + calls.append(x) |
| 923 | + return x |
| 924 | + |
| 925 | + ns = _LegacyFormat("legacy_fail") |
| 926 | + writer = cache(backend=backend, ttl=60, namespace=ns)(f) |
| 927 | + writer(1) # this wrapper's L1 now holds the entry |
| 928 | + (rid,) = _registry_ids(backend) |
| 929 | + legacy_rid = "ck:reg:legacy:" + rid.rsplit(":", 1)[1] |
| 930 | + backend.sets[legacy_rid] = {"pre-upgrade-key"} |
| 931 | + |
| 932 | + fresh = cache(backend=backend, ttl=60, namespace=ns)(f) # knows no keys itself |
| 933 | + with caplog.at_level(logging.WARNING): |
| 934 | + fresh.invalidate_cache() |
| 935 | + |
| 936 | + assert "Legacy key registry drain failed" in caplog.text |
| 937 | + assert "invalidating local keys only" not in caplog.text |
| 938 | + assert legacy_rid in backend.sets # retried by the next drain |
| 939 | + writer(1) |
| 940 | + assert calls == [1, 1] # the shared L1 copy was evicted, so it recomputed |
| 941 | + |
| 942 | + @pytest.mark.parametrize( |
| 943 | + ("namespace", "interop", "legacy"), |
| 944 | + [ |
| 945 | + ("users", None, None), |
| 946 | + (_StrEnumNS.USERS, None, None), |
| 947 | + (None, None, None), |
| 948 | + # f"{member}" is "_NS.USERS" only from 3.11; on 3.10 the set name never moved. |
| 949 | + (_NS.USERS, None, "_NS.USERS" if sys.version_info >= (3, 11) else None), |
| 950 | + # 0.20.0 shipped the registry with interop's exact-str rebind: no pre-fix set exists. |
| 951 | + (_NS.USERS, "get_user", None), |
| 952 | + ], |
| 953 | + ) |
| 954 | + def test_no_args_drain_ids(self, namespace: Optional[str], interop: Optional[str], legacy: Optional[str]) -> None: |
| 955 | + """Only a namespace whose set name actually moved gets a second drain.""" |
| 956 | + backend = TrackingBackend() |
| 957 | + |
| 958 | + @cache(backend=backend, ttl=60, namespace=namespace, interop=interop, l1_enabled=False) |
| 959 | + def f(x: int) -> int: |
| 960 | + return x |
| 961 | + |
| 962 | + f(1) |
| 963 | + (rid,) = _registry_ids(backend) |
| 964 | + assert rid.startswith(f"ck:reg:{'users' if namespace is not None else ''}:") |
| 965 | + f.invalidate_cache() |
| 966 | + expected = [rid] if legacy is None else [rid, f"ck:reg:{legacy}:{rid.rsplit(':', 1)[1]}"] |
| 967 | + assert [r for r, _ in backend.drain_calls] == expected |
0 commit comments