Files
devplacepy/tests/unit/services/nodes/dispatcher.py
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
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>
2026-10-17 02:23:43 +02:00

632 lines
23 KiB
Python

# retoor <retoor@molodetz.nl>
import base64
import json
import random
import time
import pytest
from devplacepy.database import get_table
from devplacepy.database import nodes as store
from devplacepy.services.nodes.broker import BodyError, JobBroker, clean_headers, decode_body
from devplacepy.services.nodes.circuit import CircuitBreaker
from devplacepy.services.nodes.dispatcher import JOB_HEADERS, NodeDispatcher
from devplacepy.services.nodes.ledger import UsageLedger
from devplacepy.services.nodes.policy import DestinationPolicy
from devplacepy.services.nodes.registry import NodeCounters, NodeRegistry
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
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()
self.counters = NodeCounters()
self.registry = NodeRegistry(self.counters)
self.breaker = CircuitBreaker(clock=self.clock)
self.scheduler = NodeScheduler(self.registry, self.breaker, self.clock, rng=random.Random(2))
self.broker = JobBroker()
self.dispatcher = NodeDispatcher(
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", "asn_class": "residential"}
)
session.hello = {"modes": ["fetch"]}
session.settings = settings(**overrides)
session.roll_day()
self.dispatcher.attach(session)
self.registry.admit(session)
return session
def enqueue(self, url=TARGET_URL, caller="c1", kind="user", mode="any"):
target = DestinationPolicy().check(url, "GET", mode)
return self.broker.enqueue(kind, caller, target, mode)
def tick(self):
return run_async(self.dispatcher.tick())
def answer(self, session, uid, **fields):
frame = {"type": "job_result", "job_id": uid, "status": 200, "body_b64": base64.b64encode(b"hello").decode()}
frame.update(fields)
run_async(session._handle(frame))
@pytest.fixture
def rig(local_db):
get_table("node_requests").delete()
yield Rig()
get_table("node_requests").delete()
def sent_jobs(session):
return [frame for frame in session.websocket.sent if frame["type"] == "job"]
def test_a_queued_request_becomes_a_job_frame(rig):
session = rig.connect()
uid = rig.enqueue()
assert rig.tick() == 1
job = sent_jobs(session)[0]
assert job == {"type": "job", "job_id": uid, "method": "GET", "url": TARGET_URL, "headers": JOB_HEADERS, "timeout": LIMITS.job_timeout_seconds}
assert store.get_request(uid)["status"] == "assigned"
assert store.get_request(uid)["device_uid"] == session.device_uid
assert uid in session.inflight
def test_the_result_completes_the_request_and_is_metered(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid, headers={"Content-Type": "application/json", "Set-Cookie": "x=1"})
result = rig.broker.read(uid)
assert result.ok and result.http_status == 200 and result.body == b"hello"
assert result.headers == {"content-type": "application/json"}
assert uid not in session.inflight
today = store.usage_today(session.device_uid)
assert today["requests_ok"] == 1 and today["bytes_down"] == 5 and today["bytes_up"] > len(TARGET_URL)
assert store.get_donor(session.user_uid)["requests_ok"] == 1
assert session.used_today == today["bytes_up"] + today["bytes_down"]
assert rig.counters.snapshot()["jobs_done"] == 1
def test_no_eligible_node_leaves_the_request_queued(rig):
uid = rig.enqueue()
assert rig.tick() == 0
assert store.get_request(uid)["status"] == "queued"
rig.connect(allowed_platforms=[])
assert rig.tick() == 0
assert store.get_request(uid)["status"] == "queued"
def test_one_request_goes_to_exactly_one_node(rig):
sessions = [rig.connect() for _ in range(4)]
rig.enqueue()
assert rig.tick() == 1
assert sum(len(sent_jobs(item)) for item in sessions) == 1
def test_a_caller_never_gets_its_own_node(rig):
session = rig.connect()
uid = rig.enqueue(caller=session.user_uid)
assert rig.tick() == 0
assert store.get_request(uid)["status"] == "queued"
guest = rig.enqueue(caller=session.user_uid, kind="guest")
assert rig.tick() == 1 and guest in session.inflight
def test_a_policy_failure_at_dispatch_time_fails_the_request(rig):
rig.connect()
uid = store.enqueue_request("user", "c", "https://evil.example/", "GET", "x", "any")
rig.tick()
row = store.get_request(uid)
assert row["status"] == "failed" and row["error"] == "host_not_allowed"
assert rig.counters.snapshot()["jobs_refused"] == 1
def test_a_job_error_fails_the_request_and_counts_against_the_node(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "timeout"}))
assert store.get_request(uid)["error"] == "timeout"
assert store.usage_today(session.device_uid)["requests_failed"] == 1
assert rig.counters.snapshot()["jobs_failed"] == 1
def test_five_node_failures_open_that_nodes_circuit(rig):
session = rig.connect()
for _ in range(LIMITS.breaker_failures):
uid = rig.enqueue()
session.host_started.clear()
rig.scheduler._destination_started.clear()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "network"}))
follow = rig.enqueue()
session.host_started.clear()
rig.scheduler._destination_started.clear()
assert rig.tick() == 0
assert store.get_request(follow)["status"] == "queued"
rig.clock.now += LIMITS.breaker_open_seconds
assert rig.tick() == 1
def test_donor_limit_codes_pause_the_node_without_blaming_it(rig):
session = rig.connect(bandwidth_mb_day=50)
uid = rig.enqueue()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "bandwidth_exhausted"}))
assert session.used_today == 50 * MEGABYTE
assert rig.breaker.allows(("node", session.device_uid))
other = rig.connect()
rig.clock.now += 10
uid = rig.enqueue()
assert rig.tick() == 1
run_async(other._handle({"type": "job_error", "job_id": uid, "code": "outside_hours"}))
assert other.paused_until > rig.clock.now
assert rig.breaker.allows(("node", other.device_uid))
def test_a_donor_refusal_of_an_allowed_destination_raises_a_flag(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
run_async(session._handle({"type": "job_error", "job_id": uid, "code": "host_not_allowed"}))
kinds = [flag["kind"] for flag in store.list_flags()]
assert "refusal_mismatch" in kinds
assert rig.breaker.allows(("node", session.device_uid))
@pytest.mark.parametrize(
"fields,code",
[
({"status": 99}, "bad_status"),
({"status": 600}, "bad_status"),
({"body_b64": "***not base64***"}, "bad_base64"),
({"body_b64": "abc"}, "bad_base64"),
],
)
def test_forged_results_are_integrity_failures(rig, fields, code):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid, **fields)
assert store.get_request(uid)["error"] == code
assert store.usage_today(session.device_uid)["integrity_failures"] == 1
assert rig.counters.snapshot()["integrity_failures"] == 1
assert any(flag["kind"] == "integrity" for flag in store.list_flags())
assert store.get_donor(session.user_uid)["requests_ok"] == 0
def test_an_oversized_body_is_truncated_and_flagged(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
big = base64.b64encode(b"x" * (LIMITS.max_body_bytes + 10)).decode()
rig.answer(session, uid, body_b64=big)
result = rig.broker.read(uid)
assert result.truncated and len(result.body) == LIMITS.max_body_bytes
assert rig.counters.snapshot()["results_truncated"] == 1
def test_a_donor_declared_truncation_is_kept(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid, truncated=True)
assert rig.broker.read(uid).truncated
def test_a_result_for_an_unknown_job_is_a_protocol_error(rig):
session = rig.connect()
from devplacepy.services.nodes.frames import FrameError
with pytest.raises(FrameError):
rig.answer(session, "never-assigned")
def test_a_job_cannot_be_answered_twice(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid)
from devplacepy.services.nodes.frames import FrameError
with pytest.raises(FrameError):
rig.answer(session, uid)
assert store.get_donor(session.user_uid)["requests_ok"] == 1
def test_rate_limited_and_server_error_results_open_the_destination_circuit(rig):
for index in range(LIMITS.breaker_failures):
session = rig.connect()
uid = rig.enqueue()
rig.scheduler._destination_started.clear()
rig.tick()
rig.answer(session, uid, status=429 if index % 2 else 503)
assert rig.broker.read(uid).ok
rig.registry.release(session)
assert not rig.breaker.allows(("destination", "reddit.com"))
rig.connect()
rig.enqueue()
rig.scheduler._destination_started.clear()
assert rig.tick() == 0
def test_a_healthy_result_closes_the_destination_circuit(rig):
rig.breaker.failure(("destination", "reddit.com"))
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.answer(session, uid, status=200)
assert rig.breaker.state(("destination", "reddit.com")) == "closed"
def test_a_job_that_never_answers_times_out(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
rig.clock.now += LIMITS.job_timeout_seconds + LIMITS.job_grace_seconds + 1
assert run_async(rig.dispatcher.sweep_inflight()) == 1
assert store.get_request(uid)["error"] == "timeout"
assert session.inflight == {}
def test_a_disconnect_fails_every_inflight_job(rig):
session = rig.connect(max_concurrent=2)
uids = [rig.enqueue(), rig.enqueue()]
for _ in uids:
session.host_started.clear()
rig.scheduler._destination_started.clear()
assert rig.tick() == 1
assert len(session.inflight) == 2
assert rig.dispatcher.release_session(session) == 2
for uid in uids:
row = store.get_request(uid)
assert row["status"] == "failed" and row["error"] == "node_disconnected"
assert session.inflight == {}
def test_release_session_marks_assigned_jobs_as_node_disconnected(rig):
session = rig.connect()
uid = rig.enqueue()
rig.tick()
assert rig.dispatcher.release_session(session) == 1
assert store.get_request(uid)["error"] == "node_disconnected"
assert rig.dispatcher.release_session(session) == 0
def test_a_failed_send_releases_the_job(rig):
session = rig.connect()
async def broken(*args, **kwargs):
raise ConnectionError("gone")
session.send = broken
uid = rig.enqueue()
assert rig.tick() == 0
assert store.get_request(uid)["error"] == "send_failed"
assert session.inflight == {}
def test_an_idle_tick_does_no_writes_and_no_work(rig):
rig.connect()
started = time.perf_counter()
for _ in range(50):
assert rig.tick() == 0
assert (time.perf_counter() - started) / 50 < 0.02
def test_the_cap_refusing_the_ledger_pauses_the_node(rig):
session = rig.connect(bandwidth_mb_day=1)
store.save_node_settings(session.user_uid, {"enabled": True, "bandwidth_mb_day": 1})
session.used_today = 0
uid = rig.enqueue()
rig.tick()
store.record_job(session.device_uid, session.user_uid, ok=True, bytes_down=MEGABYTE, cap_bytes=MEGABYTE)
rig.answer(session, uid)
assert rig.broker.read(uid).ok
assert session.used_today >= MEGABYTE
def test_every_assigned_job_is_answered_at_most_once_under_a_random_workload(rig):
rng = random.Random(11)
sessions = [rig.connect(max_concurrent=2) for _ in range(4)]
seen = set()
for _ in range(150):
rig.enqueue(caller=generate_uid())
rig.clock.now += 10
rig.scheduler._destination_started.clear()
rig.tick()
for session in sessions:
for job_id in list(session.inflight):
assert job_id not in seen
action = rng.random()
if action < 0.6:
rig.answer(session, job_id)
seen.add(job_id)
elif action < 0.8:
run_async(session._handle({"type": "job_error", "job_id": job_id, "code": "network"}))
seen.add(job_id)
assert len(session.inflight) <= session.max_concurrent
open_now = {job for item in sessions for job in item.inflight}
assert not (open_now & seen)
def test_body_helpers():
assert decode_body(base64.b64encode(b"abc").decode()) == (b"abc", False)
with pytest.raises(BodyError):
decode_body("!!!")
assert clean_headers({"A": "1", "set-cookie": "s", "Authorization": "x", 5: "x", "b": 5}) == {"a": "1"}
assert clean_headers("nope") == {}
many = {f"h{i}": "v" for i in range(500)}
assert len(clean_headers(many)) == 64
assert len(next(iter(clean_headers({"k": "v" * 5000}).values()))) == 1024
assert json.loads(json.dumps(clean_headers({"A": "1"}))) == {"a": "1"}
def test_a_saturated_destination_is_scanned_once_per_tick_not_once_per_request(rig, monkeypatch):
rig.connect(allowed_platforms=[])
for index in range(30):
rig.enqueue(caller=f"c{index}")
scans = []
original = rig.scheduler.candidates
monkeypatch.setattr(rig.scheduler, "candidates", lambda *a: scans.append(a) or original(*a))
assert rig.tick() == 0
assert len(scans) == 1
def test_saturation_does_not_hide_other_destinations(rig):
session = rig.connect()
for index in range(3):
rig.enqueue(caller=f"c{index}")
rig.scheduler._destination_started["reddit.com"] = rig.clock.now
assert rig.tick() == 0
rig.clock.now += 10
assert rig.tick() == 1
assert len(session.inflight) == 1
def test_the_owner_exclusion_does_not_saturate_the_destination(rig):
mine = rig.connect()
other = rig.connect()
first = rig.enqueue(caller=mine.user_uid)
second = rig.enqueue(caller=other.user_uid)
rig.clock.now += 10
rig.tick()
assert first in other.inflight
rig.clock.now += 10
rig.scheduler._destination_started.clear()
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