DevPlace CI / test (push) Failing after 44m45s
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import random
|
|
|
|
from devplacepy.services.nodes.cache import ResultCache, cache_key
|
|
from devplacepy.services.nodes.spec import SPEC
|
|
|
|
LIMITS = SPEC.limits
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.now = 100.0
|
|
|
|
def __call__(self):
|
|
return self.now
|
|
|
|
|
|
def make(**kwargs):
|
|
clock = Clock()
|
|
return ResultCache(clock=clock, **kwargs), clock
|
|
|
|
|
|
def test_hit_and_miss_counters():
|
|
cache, _ = make()
|
|
assert cache.get("GET", "u") is None
|
|
assert cache.put("GET", "u", 200, {"a": "b"}, b"xyz", False)
|
|
hit = cache.get("GET", "u")
|
|
assert hit.body == b"xyz" and hit.headers == {"a": "b"}
|
|
assert (cache.hits, cache.misses) == (1, 1)
|
|
|
|
|
|
def test_method_is_part_of_the_key():
|
|
assert cache_key("GET", "u") != cache_key("HEAD", "u")
|
|
assert cache_key("get", "u") == cache_key("GET", "u")
|
|
|
|
|
|
def test_entries_expire_after_the_ttl():
|
|
cache, clock = make(ttl_seconds=10)
|
|
cache.put("GET", "u", 200, {}, b"x", False)
|
|
clock.now += 9.9
|
|
assert cache.get("GET", "u") is not None
|
|
clock.now += 0.2
|
|
assert cache.get("GET", "u") is None
|
|
assert len(cache) == 0 and cache.size_bytes == 0
|
|
|
|
|
|
def test_ttl_is_clamped_to_the_spec_ceiling_and_zero_disables():
|
|
assert ResultCache(ttl_seconds=10**9).ttl_seconds == LIMITS.cache_ttl_seconds
|
|
off, _ = make(ttl_seconds=0)
|
|
assert off.put("GET", "u", 200, {}, b"x", False) is False
|
|
|
|
|
|
def test_errors_and_truncated_bodies_are_never_cached():
|
|
cache, _ = make()
|
|
assert not cache.put("GET", "u", 500, {}, b"x", False)
|
|
assert not cache.put("GET", "u", 503, {}, b"x", False)
|
|
assert not cache.put("GET", "u", 200, {}, b"x", True)
|
|
assert cache.put("GET", "u", 404, {}, b"x", False)
|
|
|
|
|
|
def test_an_oversized_entry_is_refused():
|
|
cache, _ = make(entry_max_bytes=10, max_bytes=100)
|
|
assert not cache.put("GET", "u", 200, {}, b"x" * 11, False)
|
|
assert cache.put("GET", "u", 200, {}, b"x" * 10, False)
|
|
|
|
|
|
def test_the_entry_cap_cannot_exceed_the_spec_ceiling():
|
|
assert ResultCache(entry_max_bytes=10**12).entry_max_bytes == LIMITS.cache_entry_max_bytes
|
|
|
|
|
|
def test_least_recently_used_goes_first():
|
|
cache, _ = make(max_bytes=30, entry_max_bytes=30)
|
|
for name in "abc":
|
|
cache.put("GET", name, 200, {}, b"x" * 10, False)
|
|
cache.get("GET", "a")
|
|
cache.put("GET", "d", 200, {}, b"x" * 10, False)
|
|
assert cache.get("GET", "b") is None
|
|
assert cache.get("GET", "a") is not None
|
|
assert cache.get("GET", "c") is not None
|
|
assert cache.get("GET", "d") is not None
|
|
|
|
|
|
def test_replacing_an_entry_does_not_leak_bytes():
|
|
cache, _ = make()
|
|
cache.put("GET", "u", 200, {}, b"x" * 100, False)
|
|
cache.put("GET", "u", 200, {}, b"y" * 40, False)
|
|
assert cache.size_bytes == 40 and len(cache) == 1
|
|
|
|
|
|
def test_size_never_exceeds_the_cap_under_a_random_workload():
|
|
cache, clock = make(max_bytes=1000, entry_max_bytes=400, ttl_seconds=50)
|
|
rng = random.Random(7)
|
|
for step in range(5000):
|
|
clock.now += rng.random()
|
|
key = f"k{rng.randint(0, 60)}"
|
|
if rng.random() < 0.7:
|
|
cache.put("GET", key, rng.choice([200, 404, 500]), {}, b"x" * rng.randint(0, 500), False)
|
|
else:
|
|
cache.get("GET", key)
|
|
assert 0 <= cache.size_bytes <= 1000
|
|
assert cache.size_bytes == sum(len(e.body) for e in cache._entries.values())
|
|
|
|
|
|
def test_clear_empties_everything():
|
|
cache, _ = make()
|
|
cache.put("GET", "u", 200, {}, b"x", False)
|
|
cache.clear()
|
|
assert len(cache) == 0 and cache.size_bytes == 0
|
|
|
|
|
|
def test_age_moves_the_expiry_forward():
|
|
cache, clock = make(ttl_seconds=100)
|
|
cache.put("GET", "https://r/x", 200, {}, b"x", False, age_seconds=90)
|
|
clock.now = 105
|
|
assert cache.get("GET", "https://r/x") is not None
|
|
clock.now = 111
|
|
assert cache.get("GET", "https://r/x") is None
|
|
|
|
|
|
def shared(local_db, **kwargs):
|
|
from devplacepy.database import nodes as store
|
|
from devplacepy.services.nodes.cache import SharedResultCache
|
|
|
|
store.cache_clear()
|
|
clock = Clock()
|
|
return SharedResultCache(clock=clock, **kwargs), clock, store
|
|
|
|
|
|
def test_a_second_process_sees_what_the_first_stored(local_db):
|
|
first, _, store = shared(local_db, ttl_seconds=60)
|
|
assert first.put("GET", "https://r/a", 200, {"content-type": "text/plain"}, b"payload", False)
|
|
from devplacepy.services.nodes.cache import SharedResultCache
|
|
|
|
second = SharedResultCache(ttl_seconds=60)
|
|
hit = second.get("GET", "https://r/a")
|
|
assert hit is not None and hit.body == b"payload" and hit.headers == {"content-type": "text/plain"}
|
|
assert (second.hits, second.misses) == (1, 0)
|
|
again = second.get("GET", "https://r/a")
|
|
assert again is not None and second.hits == 2
|
|
store.cache_clear()
|
|
|
|
|
|
def test_the_shared_layer_honours_ttl_truncation_and_server_errors(local_db):
|
|
cache, _, store = shared(local_db, ttl_seconds=60)
|
|
assert not cache.put("GET", "https://r/b", 500, {}, b"x", False)
|
|
assert not cache.put("GET", "https://r/b", 200, {}, b"x", True)
|
|
assert store.cache_stats()["entries"] == 0
|
|
cache.put("GET", "https://r/c", 200, {}, b"x", False)
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
assert store.cache_get("k-not-there") is None
|
|
key = cache_key("GET", "https://r/c")
|
|
assert store.cache_get(key) is not None
|
|
assert store.cache_get(key, datetime.now(timezone.utc) + timedelta(seconds=120)) is None
|
|
store.cache_clear()
|
|
|
|
|
|
def test_clear_empties_both_layers(local_db):
|
|
cache, _, store = shared(local_db, ttl_seconds=60)
|
|
cache.put("GET", "https://r/d", 200, {}, b"x", False)
|
|
cache.clear()
|
|
assert len(cache) == 0 and store.cache_stats()["entries"] == 0
|
|
|
|
|
|
def test_the_shared_layer_prunes_expired_rows_and_evicts_oldest_over_the_cap(local_db):
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
cache, _, store = shared(local_db, ttl_seconds=60)
|
|
now = datetime.now(timezone.utc)
|
|
for index in range(4):
|
|
store.cache_put(f"k{index}", "GET", f"https://r/{index}", 200, "{}", "AAAA", 1000, 60, now + timedelta(seconds=index))
|
|
store.cache_put("old", "GET", "https://r/old", 200, "{}", "AAAA", 10, 60, now - timedelta(seconds=300))
|
|
removed = store.cache_prune(2500)
|
|
assert removed["expired"] == 1 and removed["evicted"] == 2 and removed["bytes"] == 2000
|
|
left = store.cache_stats()
|
|
assert left["entries"] == 2
|
|
assert store.cache_get("k0") is None and store.cache_get("k3") is not None
|
|
store.cache_clear()
|