forked from retoor/devplacepy
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user