416 lines
15 KiB
Python
416 lines
15 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
|
|
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"
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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"}
|
|
)
|
|
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
|