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>
632 lines
23 KiB
Python
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
|