Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work

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>
This commit is contained in:
2026-10-17 02:23:43 +02:00
co-authored by Claude Sonnet 5.5
parent ce899fc5e1
commit 02ab747cd0
203 changed files with 11624 additions and 301 deletions
+56
View File
@@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.services.nodes.addressclass import (
CLASS_DATACENTER,
CLASS_MOBILE,
CLASS_RESIDENTIAL,
CLASS_UNKNOWN,
DATACENTER_ASNS,
ELIGIBLE_CLASSES,
classify,
)
from devplacepy.services.nodes.spec import ASN_CLASSES
def test_the_classes_are_exactly_the_specified_ones():
assert set(ELIGIBLE_CLASSES) | {CLASS_DATACENTER, CLASS_UNKNOWN} == set(ASN_CLASSES)
@pytest.mark.parametrize(
"asn,org,expected",
[
(24940, "Hetzner Online GmbH", CLASS_DATACENTER),
(16509, "", CLASS_DATACENTER),
(64500, "Acme Web Hosting Ltd", CLASS_DATACENTER),
(64500, "Fast Cloud Services", CLASS_DATACENTER),
(64500, "Some VPN Provider", CLASS_DATACENTER),
(64500, "Data Center Holdings", CLASS_DATACENTER),
(21928, "T-Mobile USA, Inc.", CLASS_MOBILE),
(64500, "Example Wireless", CLASS_MOBILE),
(3320, "Deutsche Telekom AG", CLASS_RESIDENTIAL),
(1136, "KPN B.V.", CLASS_RESIDENTIAL),
(0, "", CLASS_UNKNOWN),
(None, None, CLASS_UNKNOWN),
("", "", CLASS_UNKNOWN),
],
)
def test_classification_table(asn, org, expected):
assert classify(asn, org) == expected
def test_keywords_match_whole_words_only():
assert classify(64500, "Hostingly Residential Broadband") == CLASS_RESIDENTIAL
assert classify(64500, "Cloudless Telecom") == CLASS_RESIDENTIAL
assert classify(64500, "Wirelesstown ISP") == CLASS_RESIDENTIAL
def test_a_known_asn_wins_over_a_residential_name():
assert 14061 in DATACENTER_ASNS
assert classify(14061, "Friendly Home Internet") == CLASS_DATACENTER
def test_garbage_asn_input_is_unknown_or_name_driven():
assert classify("not-a-number", "") == CLASS_UNKNOWN
assert classify("not-a-number", "Plain ISP") == CLASS_RESIDENTIAL
+70
View File
@@ -107,3 +107,73 @@ def test_clear_empties_everything():
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()
+54
View File
@@ -95,3 +95,57 @@ def test_a_zero_threshold_still_needs_one_failure():
assert breaker.allows("x")
breaker.failure("x")
assert not breaker.allows("x")
def test_snapshot_lists_open_circuits_with_time_left():
breaker, clock = make(failures=2, open_seconds=60)
breaker.failure(("destination", "reddit.com"))
breaker.failure(("destination", "reddit.com"))
clock.now = 20
rows = breaker.snapshot()
assert rows[0]["key"] == "destination:reddit.com" and rows[0]["state"] == "open"
assert 39 <= rows[0]["seconds_left"] <= 40 and rows[0]["failures"] == 2
def test_dirty_flag_follows_every_change():
breaker, _ = make(failures=2)
assert not breaker.dirty
breaker.failure("x")
assert breaker.dirty
breaker.dirty = False
breaker.success("x")
assert breaker.dirty
breaker.dirty = False
breaker.success("never-seen")
assert not breaker.dirty
def test_a_restart_keeps_an_open_circuit_open_until_its_time_is_up():
wall = {"now": 1_000_000.0}
clock = Clock()
first = CircuitBreaker(2, 60, clock, wall=lambda: wall["now"])
first.failure(("destination", "reddit.com"))
first.failure(("destination", "reddit.com"))
clock.now = 10
wall["now"] += 10
saved = first.snapshot()
second_clock = Clock()
second_clock.now = 5000
second = CircuitBreaker(2, 60, second_clock, wall=lambda: wall["now"] + 5)
assert second.restore(saved) == 1
assert second.state(("destination", "reddit.com")) == OPEN
second_clock.now += 60
assert second.state(("destination", "reddit.com")) == HALF_OPEN
def test_restore_ignores_closed_ancient_and_malformed_rows():
wall = {"now": 1_000_000.0}
breaker = CircuitBreaker(3, 60, Clock(), wall=lambda: wall["now"])
rows = [
{"key": "destination:a", "failures": 1, "opened_at": wall["now"]},
{"key": "destination:b", "failures": 3, "opened_at": wall["now"] - 10_000},
{"key": "nocolon", "failures": 9, "opened_at": wall["now"]},
{"key": "destination:c", "failures": 9, "opened_at": 0},
]
assert breaker.restore(rows) == 0
assert breaker.open_count() == 0
+217 -1
View File
@@ -19,6 +19,9 @@ from devplacepy.services.nodes.scheduler import MEGABYTE, NodeScheduler
from devplacepy.services.nodes.session import NodeSession
from devplacepy.services.nodes.spec import SPEC
from devplacepy.utils import generate_uid
import asyncio
from concurrent.futures import ThreadPoolExecutor
from tests.conftest import run_async
from tests.unit.services.nodes.scheduler import Clock, node, settings
@@ -26,6 +29,11 @@ LIMITS = SPEC.limits
TARGET_URL = "https://www.reddit.com/r/python.json"
def run_in_thread(coro):
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
class Rig:
def __init__(self):
self.clock = Clock()
@@ -38,12 +46,13 @@ class Rig:
self.registry, self.scheduler, self.broker, self.breaker, UsageLedger(), self.counters,
clock=self.clock,
)
self.dispatcher.redundancy_percent = staticmethod(lambda: 0)
def connect(self, **overrides):
owner = generate_uid()
device, _ = store.create_device(owner, "d", "native")
session = NodeSession(
node().websocket, {"uid": device["uid"], "user_uid": owner, "kind": "native"}
node().websocket, {"uid": device["uid"], "user_uid": owner, "kind": "native", "asn_class": "residential"}
)
session.hello = {"modes": ["fetch"]}
session.settings = settings(**overrides)
@@ -413,3 +422,210 @@ def test_the_owner_exclusion_does_not_saturate_the_destination(rig):
other.host_started.clear()
rig.tick()
assert second in mine.inflight
def test_the_claim_records_the_donor_address_class(rig):
session = rig.connect()
session.asn_class = "mobile"
uid = rig.enqueue()
rig.tick()
assert store.get_request(uid)["device_asn_class"] == "mobile"
def test_outcomes_are_observed_per_class(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid)
other = rig.enqueue(url="https://www.reddit.com/r/two.json")
rig.clock.now += 100
rig.tick()
rig.answer(session, other, status=403)
third = rig.enqueue(url="https://www.reddit.com/r/three.json")
rig.clock.now += 100
rig.tick()
rig.answer(session, third, status=404)
seen = rig.counters.outcomes()
assert seen["outcome:reddit:residential:ok"] == 2 and seen["outcome:reddit:residential:blocked"] == 1
def test_a_node_failure_is_observed_but_a_donor_limit_is_not(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "fetch_failed"}))
assert rig.counters.outcomes().get("outcome:reddit:residential:failed") == 1
rig.clock.now += 100
session.paused_until = 0
second = rig.enqueue(url="https://www.reddit.com/r/two.json")
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": second, "code": "outside_hours"}))
assert rig.counters.outcomes().get("outcome:reddit:residential:failed") == 1
def test_on_battery_pauses_the_donor_without_blaming_it(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "on_battery"}))
assert session.paused_until > rig.clock()
assert not rig.breaker.failures or rig.breaker.allows(("node", session.device_uid))
assert store.get_request(uid)["error"] == "on_battery"
class SampleAll:
def __init__(self, rig):
rig.dispatcher.redundancy_percent = staticmethod(lambda: 100)
def mismatch_flags(device_uid=None):
return [
flag
for flag in store.list_flags()
if flag["kind"] == "redundancy_mismatch" and (device_uid is None or flag["device_uid"] == device_uid)
]
def sent_to(session):
return [frame["job_id"] for frame in session.websocket.sent if frame["type"] == "job"]
def test_a_sampled_fetch_result_is_cross_checked_on_a_different_donor(rig):
SampleAll(rig)
first, second = rig.connect(), rig.connect()
uid = rig.enqueue()
rig.tick()
holder = first if uid in sent_to(first) else second
other = second if holder is first else first
rig.answer(holder, uid, headers={"content-type": "application/json"}, body_b64=base64.b64encode(b'{"kind":"Listing","data":{}}').decode())
shadows = list(get_table("node_requests").find(shadow_of=uid))
assert len(shadows) == 1 and shadows[0]["exclude_device"] == holder.device_uid
assert shadows[0]["caller_kind"] == "shadow"
rig.clock.now += 100
assert rig.tick() == 1
assert sent_to(other) and shadows[0]["uid"] in sent_to(other)
rig.answer(other, shadows[0]["uid"], headers={"content-type": "application/json"}, body_b64=base64.b64encode(b'{"kind":"Listing","data":{}}').decode())
counts = rig.counters.snapshot()
assert counts["redundancy_checked"] == 1 and counts["redundancy_matched"] == 1
assert not mismatch_flags(other.device_uid)
def test_a_forged_second_opinion_raises_a_flag_and_no_ban(rig):
SampleAll(rig)
first, second = rig.connect(), rig.connect()
uid = rig.enqueue()
rig.tick()
holder = first if uid in sent_to(first) else second
other = second if holder is first else first
rig.answer(holder, uid, headers={"content-type": "application/json"}, body_b64=base64.b64encode(b'{"kind":"Listing","data":{}}').decode())
shadow = list(get_table("node_requests").find(shadow_of=uid))[0]["uid"]
rig.clock.now += 100
rig.tick()
rig.answer(other, shadow, headers={"content-type": "application/json"}, body_b64=base64.b64encode(b'{"buy":"now"}').decode())
assert rig.counters.snapshot()["redundancy_mismatches"] == 1
flags = mismatch_flags(other.device_uid)
assert len(flags) == 1
assert effective_status_of(other) == "active"
def effective_status_of(session):
return store.effective_status(store.get_device(session.device_uid))
def test_a_shadow_never_spends_the_caller_quota_and_is_not_cross_checked_again(rig):
SampleAll(rig)
first, second = rig.connect(), rig.connect()
uid = rig.enqueue()
rig.tick()
holder = first if uid in sent_to(first) else second
other = second if holder is first else first
rig.answer(holder, uid)
shadow = list(get_table("node_requests").find(shadow_of=uid))[0]["uid"]
rig.clock.now += 100
rig.tick()
rig.answer(other, shadow)
assert get_table("node_requests").count(shadow_of={"!=": ""}) == 1
def test_sampling_is_deterministic_and_close_to_the_requested_rate():
uids = [generate_uid() for _ in range(10000)]
chosen = [uid for uid in uids if NodeDispatcher.sampled(uid, 5)]
assert chosen == [uid for uid in uids if NodeDispatcher.sampled(uid, 5)]
assert 350 <= len(chosen) <= 650
assert not any(NodeDispatcher.sampled(uid, 0) for uid in uids[:500])
assert all(NodeDispatcher.sampled(uid, 100) for uid in uids[:500])
def test_the_percent_is_clamped_to_the_spec_ceiling(local_db):
from devplacepy.database import set_setting
set_setting("nodes_redundancy_percent", "99")
try:
assert NodeDispatcher.redundancy_percent() == LIMITS.redundancy_max_percent
set_setting("nodes_redundancy_percent", "-4")
assert NodeDispatcher.redundancy_percent() == 0
finally:
set_setting("nodes_redundancy_percent", str(LIMITS.redundancy_percent))
def test_a_missing_original_is_inconclusive_not_a_flag(rig):
session = rig.connect()
rig.dispatcher._compare_shadow(session, type("J", (), {"shadow_of": "gone", "destination": "reddit.com"})(), 200, {}, b"x")
assert rig.counters.snapshot()["redundancy_inconclusive"] == 1
assert not mismatch_flags(session.device_uid)
def test_a_waiter_in_the_dispatcher_process_wakes_without_polling(rig):
import asyncio
from devplacepy.services.nodes.wake import HUB
async def scenario():
HUB.attach()
target = DestinationPolicy().check(TARGET_URL)
uid = rig.broker.enqueue("user", "c1", target, "any")
store.claim_request(uid, "d1")
loop = asyncio.get_running_loop()
loop.call_later(0.02, lambda: rig.broker.complete(uid, 200, {}, b"ok", False))
started = time.monotonic()
result = await rig.broker.wait(uid, 5)
return result, time.monotonic() - started
try:
result, elapsed = run_in_thread(scenario())
finally:
HUB.enabled = True
assert result.ok and result.body == b"ok"
assert elapsed < LIMITS.poll_interval_ms / 1000
def test_a_waiter_without_the_hub_still_finishes_by_polling(rig):
import asyncio
from devplacepy.services.nodes.wake import HUB
async def scenario():
target = DestinationPolicy().check(TARGET_URL)
uid = rig.broker.enqueue("user", "c2", target, "any")
store.claim_request(uid, "d1")
asyncio.get_running_loop().call_later(0.02, lambda: rig.broker.complete(uid, 200, {}, b"ok", False))
return await rig.broker.wait(uid, 5)
HUB._loop = None
assert run_in_thread(scenario()).ok
def test_enqueue_signals_the_dispatcher(rig):
import asyncio
from devplacepy.services.nodes.wake import HUB
async def scenario():
HUB.attach()
target = DestinationPolicy().check(TARGET_URL)
woke = asyncio.ensure_future(HUB.wait_queued(5))
await asyncio.sleep(0)
rig.broker.enqueue("user", "c3", target, "any")
return await asyncio.wait_for(woke, 1)
assert run_in_thread(scenario()) is True
+2
View File
@@ -60,7 +60,9 @@ def online(local_db):
set_setting("nodes_daily_quota_user", "1000")
set_setting("nodes_daily_quota_guest", "0")
heartbeat(3)
store.cache_clear()
yield
store.cache_clear()
set_setting("nodes_enabled", "0")
get_table("service_state").delete(name="nodes")
+126
View File
@@ -0,0 +1,126 @@
# retoor <retoor@molodetz.nl>
import gzip
from pathlib import Path
import pytest
from devplacepy.services.nodes import geo
from devplacepy.services.nodes.geo import GeoDatabase, GeoError, build, pack
ASN_ROWS = [
("1.0.0.0", "1.0.0.255", 13335, "Cloudflare, Inc."),
("8.8.8.0", "8.8.8.255", 15169, "Google LLC"),
("20.0.0.0", "20.0.0.255", 64500, "Home Fiber BV"),
("2001:db8::", "2001:db8::ffff", 64501, "Mobile Wireless NV"),
]
COUNTRY_ROWS = [
("1.0.0.0", "1.0.0.255", "AU"),
("8.8.8.0", "8.8.8.255", "US"),
("20.0.0.0", "20.0.0.255", "NL"),
("2001:db8::", "2001:db8::ffff", "ZZ"),
]
def lines(rows):
return [",".join(f'"{part}"' if "," in str(part) else str(part) for part in row) for row in rows]
def make(tmp_path, monkeypatch, checks=()):
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(min_asn_rows=1, min_country_rows=1, checks=checks))
target = tmp_path / "geo.db"
built = build(lines(ASN_ROWS), lines(COUNTRY_ROWS), target, "2026-10")
geo.install(built, target)
return GeoDatabase(target)
def test_pack_normalises_v4_mapped_and_rejects_garbage():
assert pack("::ffff:8.8.8.8") == pack("8.8.8.8")
assert pack("[2001:db8::1]")[0] == 6
with pytest.raises(ValueError):
pack("nope")
def test_lookup_hits_first_last_and_middle_of_a_range(tmp_path, monkeypatch):
database = make(tmp_path, monkeypatch)
for address in ("1.0.0.0", "1.0.0.77", "1.0.0.255"):
found = database.lookup(address)
assert (found.asn, found.country, found.asn_class) == (13335, "AU", "datacenter")
def test_lookup_between_and_outside_ranges_is_unknown(tmp_path, monkeypatch):
database = make(tmp_path, monkeypatch)
for address in ("1.0.1.0", "0.255.255.255", "9.9.9.9", "255.255.255.255"):
assert database.lookup(address).asn_class == "unknown"
def test_ipv6_and_zz_country(tmp_path, monkeypatch):
database = make(tmp_path, monkeypatch)
found = database.lookup("2001:db8::1234")
assert found.asn == 64501 and found.country == "" and found.asn_class == "mobile"
assert database.lookup("2001:db9::1").asn == 0
def test_classification_of_a_residential_range(tmp_path, monkeypatch):
assert make(tmp_path, monkeypatch).lookup("20.0.0.9").asn_class == "residential"
def test_missing_file_and_bad_input_degrade_to_unknown(tmp_path):
database = GeoDatabase(tmp_path / "absent.db")
assert not database.available()
assert database.lookup("8.8.8.8").asn_class == "unknown"
assert database.status()["available"] is False
assert GeoDatabase(tmp_path / "absent.db").lookup("").asn_class == "unknown"
def test_a_too_small_dataset_is_refused_and_leaves_no_file(tmp_path, monkeypatch):
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(min_asn_rows=10, min_country_rows=10, checks=()))
target = tmp_path / "geo.db"
with pytest.raises(GeoError):
build(lines(ASN_ROWS), lines(COUNTRY_ROWS), target, "2026-10")
assert not target.exists() and not target.with_suffix(".tmp").exists()
def test_a_failed_sanity_check_refuses_and_keeps_the_current_database(tmp_path, monkeypatch):
current = make(tmp_path, monkeypatch)
before = current.path.read_bytes()
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(min_asn_rows=1, min_country_rows=1, checks=(("8.8.8.8", 99999),)))
with pytest.raises(GeoError):
build(lines(ASN_ROWS), lines(COUNTRY_ROWS), tmp_path / "geo.db", "2026-11")
assert current.path.read_bytes() == before
def test_the_row_cap_is_enforced(tmp_path, monkeypatch):
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(max_rows=2, min_asn_rows=1, min_country_rows=1, checks=()))
with pytest.raises(GeoError):
build(lines(ASN_ROWS), lines(COUNTRY_ROWS), tmp_path / "geo.db", "2026-10")
def test_malformed_rows_are_skipped_not_fatal(tmp_path, monkeypatch):
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(min_asn_rows=1, min_country_rows=1, checks=()))
asn = lines(ASN_ROWS) + ["garbage", "a,b,c,d", "1.2.3.4,1.2.3.5,notanumber,x"]
built = build(asn, lines(COUNTRY_ROWS), tmp_path / "geo.db", "2026-10")
assert built["asn_rows"] == len(ASN_ROWS)
def test_status_reports_metadata_and_attribution(tmp_path, monkeypatch):
status = make(tmp_path, monkeypatch).status()
assert status["available"] and status["month"] == "2026-10" and "DB-IP" in status["attribution"]
def test_months_walk_back_over_a_year_boundary():
from datetime import datetime, timezone
assert geo.months(datetime(2026, 1, 15, tzinfo=timezone.utc)) == ["2026-01", "2025-12", "2025-11"]
def test_build_from_files_reads_gzip(tmp_path, monkeypatch):
monkeypatch.setattr(geo, "SPEC", geo.GeoSpec(min_asn_rows=1, min_country_rows=1, checks=()))
asn_file, country_file = tmp_path / "a.csv.gz", tmp_path / "c.csv.gz"
with gzip.open(asn_file, "wt") as handle:
handle.write("\n".join(lines(ASN_ROWS)))
with gzip.open(country_file, "wt") as handle:
handle.write("\n".join(lines(COUNTRY_ROWS)))
built = geo.build_from_files(asn_file, country_file, tmp_path / "geo.db", "2026-10")
geo.install(built, tmp_path / "geo.db")
assert GeoDatabase(tmp_path / "geo.db").lookup("8.8.8.8").asn == 15169
+55
View File
@@ -0,0 +1,55 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.services.nodes.registry import (
COUNTER_NAMES,
NodeCounters,
outcome_name,
)
def test_inc_counts_both_the_live_view_and_the_pending_delta():
counters = NodeCounters()
counters.inc("kicked")
counters.inc("kicked")
assert counters.snapshot()["kicked"] == 2
assert counters.take_pending() == {"kicked": 2}
assert counters.take_pending() == {}
assert counters.snapshot()["kicked"] == 2
def test_unknown_names_are_rejected():
counters = NodeCounters()
with pytest.raises(KeyError):
counters.inc("nope")
def test_restore_pending_adds_back_what_a_failed_flush_took():
counters = NodeCounters()
counters.inc("jobs_done")
taken = counters.take_pending()
counters.inc("jobs_done")
counters.restore_pending(taken)
assert counters.take_pending() == {"jobs_done": 2}
def test_outcomes_are_validated_and_bounded_in_shape():
counters = NodeCounters()
counters.observe("reddit", "residential", "ok")
counters.observe("reddit", "residential", "ok")
counters.observe("reddit", "", "blocked")
assert counters.outcomes() == {
"outcome:reddit:residential:ok": 2,
"outcome:reddit:unknown:blocked": 1,
}
for adapter, asn_class, kind in (("Re:dit", "x", "ok"), ("reddit", "a b", "ok"), ("reddit", "x", "weird"), ("x" * 40, "x", "ok")):
with pytest.raises(KeyError):
outcome_name(adapter, asn_class, kind)
def test_every_declared_counter_can_be_incremented():
counters = NodeCounters()
for name in COUNTER_NAMES:
counters.inc(name)
assert all(value == 1 for value in counters.snapshot().values())
+46 -1
View File
@@ -43,7 +43,7 @@ def settings(**overrides):
def node(uid="d1", user="u1", modes=("fetch",), **overrides):
session = NodeSession(FakeSocket(), {"uid": uid, "user_uid": user, "kind": "native"})
session = NodeSession(FakeSocket(), {"uid": uid, "user_uid": user, "kind": "native", "asn_class": "residential"})
session.hello = {"modes": list(modes)}
session.settings = settings(**overrides)
session.roll_day()
@@ -281,3 +281,48 @@ def test_choose_applies_the_owner_exclusion_to_a_shared_candidate_list(world):
assert world.choose(found, "bob") is mine
assert world.choose([mine], "alice") is None
assert world.choose([], "") is None
def test_a_datacenter_donor_is_not_scheduled(world):
session = add(world)
session.asn_class = "datacenter"
assert world.pick(TARGET) is None
def test_an_unknown_class_is_refused_unless_the_site_allows_it(world, local_db):
from devplacepy.database import set_setting
session = add(world)
session.asn_class = "unknown"
assert world.pick(TARGET) is None
set_setting("nodes_allow_unknown_class", "1")
try:
assert world.pick(TARGET) is session
session.asn_class = "datacenter"
assert world.pick(TARGET) is None
finally:
set_setting("nodes_allow_unknown_class", "0")
def test_mobile_and_residential_are_eligible(world):
for index, asn_class in enumerate(("mobile", "residential")):
session = add(world, uid=f"c{index}", user=f"cu{index}")
session.asn_class = asn_class
assert len(world.candidates(TARGET)) == 2
def test_an_admin_override_beats_the_class(world):
session = add(world)
session.asn_class = "datacenter"
session.class_override = "allow"
assert world.pick(TARGET) is session
session.class_override = ""
assert world.pick(TARGET) is None
def test_class_refusals_are_counted(world):
session = add(world)
session.asn_class = "datacenter"
before = world.class_refusals
world.candidates(TARGET)
assert world.class_refusals > before
+75
View File
@@ -176,3 +176,78 @@ def test_the_dispatch_loop_starts_once_and_stops_with_the_service(service):
assert service._dispatch_task is None and first.cancelled()
run_async(scenario())
def test_a_timed_suspension_tells_the_node_when_it_ends(service):
uid = member()
device, socket = connected(service, uid)
suspend_device(device["uid"], "test", 30)
reconcile(service)
frame = socket.sent[-1]
assert frame["reason"] == "suspended" and frame["until"].startswith("20")
def test_other_kicks_carry_no_until(service):
_, socket = connected(service, member())
set_setting("nodes_enabled", "0")
reconcile(service)
assert "until" not in socket.sent[-1]
def test_persist_state_touches_flushes_and_saves_circuits(service):
from devplacepy.database import nodes as store
uid = member()
device, _ = connected(service, uid)
get_table("node_devices").update({"uid": device["uid"], "last_seen_at": "2000-01-01T00:00:00+00:00"}, ["uid"])
store.flush_counters({})
service.counters.inc("kicked")
service.counters.observe("reddit", "residential", "ok")
service.breaker.failure(("destination", "reddit.com"))
service.persist_state(force=True)
assert store.get_device(device["uid"])["last_seen_at"] > "2020"
lifetime = store.lifetime_counters()
assert lifetime["kicked"] >= 1 and lifetime["outcome:reddit:residential:ok"] >= 1
assert service.counters.take_pending() == {}
assert not service.breaker.dirty
def test_a_failed_flush_keeps_the_deltas(service, monkeypatch):
from devplacepy.services.nodes import service as module
service.counters.inc("kicked")
def boom(deltas):
raise RuntimeError("disk full")
monkeypatch.setattr(module, "flush_counters", boom)
with pytest.raises(RuntimeError):
service.persist_state(force=True)
assert service.counters.take_pending() == {"kicked": 1}
def test_persist_state_respects_the_intervals(service, monkeypatch):
from devplacepy.services.nodes import service as module
connected(service, member())
calls = []
monkeypatch.setattr(module, "touch_devices", lambda uids: calls.append(list(uids)))
service.persist_state(force=True)
service.persist_state()
assert len(calls) == 1
def test_open_circuits_survive_a_restart(service):
from devplacepy.database.nodes import load_circuits
for _ in range(service.breaker.failures):
service.breaker.failure(("destination", "reddit.com"))
service.persist_state()
assert [row["key"] for row in load_circuits()] == ["destination:reddit.com"]
fresh = NodesService()
run_async(fresh.on_enable())
assert fresh.breaker.open_count() == 1
fresh.breaker.success(("destination", "reddit.com"))
fresh.persist_state()
assert load_circuits() == []
run_async(fresh.on_disable())
+70
View File
@@ -0,0 +1,70 @@
# retoor <retoor@molodetz.nl>
import json
from devplacepy.services.nodes.shape import INCONCLUSIVE, MATCH, MISMATCH, compare, shape_of
JSON = {"content-type": "application/json; charset=utf-8"}
HTML = {"content-type": "text/html"}
def listing(children):
return json.dumps({"kind": "Listing", "data": {"after": None, "children": children}}).encode()
def test_two_listings_with_different_content_match():
first = shape_of(200, JSON, listing([{"id": 1}]))
second = shape_of(200, JSON, listing([{"id": 2}, {"id": 3}]))
assert compare(first, second) == MATCH
def test_a_forged_json_shape_mismatches():
first = shape_of(200, JSON, listing([]))
forged = shape_of(200, JSON, json.dumps({"message": "buy now"}).encode())
assert compare(first, forged) == MISMATCH
def test_html_titles_decide():
page = b"<html><head><title> Python : r/python </title></head><body>" + b"x" * 600 + b"</body></html>"
same = b"<html><head><title>Python : r/python</title></head><body>" + b"y" * 700 + b"</body></html>"
other = b"<html><head><title>Totally different</title></head><body>" + b"x" * 600 + b"</body></html>"
assert compare(shape_of(200, HTML, page), shape_of(200, HTML, same)) == MATCH
assert compare(shape_of(200, HTML, page), shape_of(200, HTML, other)) == MISMATCH
def test_html_without_a_title_falls_back_to_the_first_heading():
first = shape_of(200, HTML, b"<h1>Hello <b>world</b></h1>")
second = shape_of(200, HTML, b"<h1>Hello world</h1>")
assert first.signature == "Hello world" and compare(first, second) == MATCH
def test_a_kind_change_mismatches_even_with_the_same_status():
assert compare(shape_of(200, JSON, listing([])), shape_of(200, HTML, b"<title>x</title>")) == MISMATCH
def test_blocked_or_error_responses_are_never_conclusive():
ok = shape_of(200, JSON, listing([]))
for status in (403, 429, 500, 404):
assert compare(ok, shape_of(status, HTML, b"<title>blocked</title>")) == INCONCLUSIVE
assert compare(shape_of(status, HTML, b""), ok) == INCONCLUSIVE
def test_a_wild_size_difference_mismatches_only_for_real_bodies():
big = shape_of(200, HTML, b"<title>t</title>" + b"a" * 5000)
small = shape_of(200, HTML, b"<title>t</title>" + b"a" * 600)
tiny = shape_of(200, HTML, b"<title>t</title>")
assert compare(big, small) == MISMATCH
assert compare(big, tiny) == MATCH
def test_invalid_json_is_its_own_kind():
broken = shape_of(200, JSON, b"{not json")
assert broken.kind == "json-invalid"
assert compare(broken, shape_of(200, JSON, listing([]))) == MISMATCH
def test_nested_key_sets_are_bounded_and_unknown_types_compare_by_type():
deep = {"a": {"b": {"c": {"d": 1}}}}
assert "a.b" in shape_of(200, JSON, json.dumps(deep).encode()).signature
assert "a.b.c.d" not in shape_of(200, JSON, json.dumps(deep).encode()).signature
assert compare(shape_of(200, {"content-type": "image/png"}, b"1"), shape_of(200, {"content-type": "image/png"}, b"2")) == MATCH
+90
View File
@@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
import time
from devplacepy.services.nodes.wake import WakeHub
def run(coro):
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
def test_a_disabled_or_detached_hub_just_sleeps():
async def scenario():
hub = WakeHub()
started = time.monotonic()
assert await hub.wait_queued(0.05) is False
assert time.monotonic() - started >= 0.04
hub.enabled = False
hub.notify_queued()
assert hub.job_event("x") is None
run(scenario())
def test_notify_wakes_the_dispatcher_immediately():
async def scenario():
hub = WakeHub()
hub.attach()
asyncio.get_running_loop().call_later(0.02, hub.notify_queued)
started = time.monotonic()
assert await hub.wait_queued(5) is True
assert time.monotonic() - started < 1
run(scenario())
def test_a_notification_before_the_wait_is_not_lost():
async def scenario():
hub = WakeHub()
hub.attach()
hub.notify_queued()
assert await hub.wait_queued(5) is True
assert await hub.wait_queued(0.02) is False
run(scenario())
def test_job_events_are_per_job_and_only_for_the_attached_loop():
async def scenario():
hub = WakeHub()
assert hub.job_event("a") is None
hub.attach()
first, second = hub.job_event("a"), hub.job_event("b")
assert first is not second and hub.job_event("a") is first
hub.job_done("a")
assert first.is_set() and not second.is_set()
hub.forget("a")
assert hub.job_event("a") is not first
run(scenario())
def test_another_loop_gets_no_event():
hub = WakeHub()
seen = []
async def attach():
hub.attach()
run(attach())
async def foreign():
seen.append(hub.job_event("z"))
run(foreign())
assert seen == [None]
def test_notifying_from_another_thread_wakes_the_loop():
async def scenario():
hub = WakeHub()
hub.attach()
threading.Timer(0.02, hub.notify_queued).start()
assert await hub.wait_queued(5) is True
run(scenario())