198 lines
10 KiB
Python
198 lines
10 KiB
Python
"""Retain BUILD97 delivery guarantees with bounded acknowledged BUILD192 batches.
|
|
|
|
The old giant-batch/repartition implementation is obsolete. The requirements
|
|
remain: ordered large mappings, bounded packets and retries, isolated failures,
|
|
independent devices, and truthful operator diagnostics.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from hockey_data.agent_bridge import MAPPING_BATCH_MAX_BYTES, MAPPING_BATCH_MAX_COMMANDS, VmixAgentHub
|
|
from tests.support import LocalTestDatabase
|
|
from tests.test_hotfix_mapping_transport import frame_commands, sent_commands, transport
|
|
from tests.test_stable_vmix_input_identity import _run_functions
|
|
|
|
|
|
def _entry(index: int, input_ref: str, *, value_size: int = 4) -> dict:
|
|
return {
|
|
"data_key": f"source.row.{index}",
|
|
"command": {
|
|
"Function": "SetText",
|
|
"Input": input_ref,
|
|
"SelectedName": f"Field{index}.Text",
|
|
"Value": "X" * value_size,
|
|
},
|
|
}
|
|
|
|
|
|
def test_mapping_chunk_planner_groups_by_input_and_caps_packets(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-chunks.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
|
|
entries = [*[_entry(i, "INPUT-A") for i in range(95)], *[_entry(100 + i, "INPUT-B") for i in range(17)]]
|
|
chunks = hub._mapping_batch_chunks(entries)
|
|
|
|
# This reusable planner retains its count/byte bound. Actual Mapping wire
|
|
# delivery below has the stricter six independent targets per packet.
|
|
assert len(chunks) == 4 # A: 40 + 40 + 15, B: 17
|
|
assert all(len(chunk) <= MAPPING_BATCH_MAX_COMMANDS for chunk in chunks)
|
|
assert all(len({item[1]["command"]["Input"] for item in chunk}) == 1 for chunk in chunks)
|
|
# ACK positions can still be restored to original Mapping order.
|
|
assert [index for chunk in chunks for index, _entry_item in chunk] == list(range(len(entries)))
|
|
|
|
# The byte bound remains effective even when the six-command cap permits
|
|
# more entries. A single giant initial packet must not return accidentally.
|
|
large = [_entry(i, "INPUT-A", value_size=20000) for i in range(7)]
|
|
large_chunks = hub._mapping_batch_chunks(large)
|
|
assert [index for chunk in large_chunks for index, _ in chunk] == list(range(7))
|
|
assert all(len(chunk) <= 2 for chunk in large_chunks)
|
|
import json
|
|
assert all(len(json.dumps([entry["command"] for _, entry in chunk], ensure_ascii=False).encode())
|
|
<= MAPPING_BATCH_MAX_BYTES for chunk in large_chunks)
|
|
|
|
|
|
def test_372_mapping_fields_are_ordered_acknowledged_and_bounded(tmp_path: Path):
|
|
hub, own, other = transport(tmp_path)
|
|
rows = [_entry(input_index * 1000 + row, f"INPUT-{input_index:02d}")
|
|
for input_index in range(12) for row in range(31)]
|
|
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER",
|
|
assignment_id="assignment", match_id="g", entries=rows, use_batch=True))
|
|
assert len(result["results"]) == 372 and all(item["ok"] for item in result["results"])
|
|
assert result["transport"] == "bounded_batch_ack" and result["ack_waited"] is True
|
|
assert result["commands_sent"] == 372 and result["input_groups"] == 12
|
|
assert own.max_pending == 1 and other.frames == []
|
|
commands = own.frames[1:]
|
|
assert own.frames[0]["type"] == "match.assign"
|
|
assert all(frame["type"] == "vmix.batch" and 1 <= len(frame["commands"]) <= 6 for frame in commands)
|
|
assert all(len({(command["Input"], command["SelectedName"]) for command in frame["commands"]})
|
|
== len(frame["commands"]) for frame in commands)
|
|
assert sent_commands(own) == [row["command"] for row in rows]
|
|
assert len(commands) == 62 # independent targets fill packets across Input boundaries
|
|
assert len({frame["request_id"] for frame in commands}) == len(commands)
|
|
assert all(frame["assignment_id"] == "assignment" and frame["match_id"] == "g" for frame in commands)
|
|
assert not hub._pending_commands
|
|
|
|
|
|
def test_mapping_transport_retries_one_timeout_then_keeps_order(tmp_path: Path):
|
|
hub, own, other = transport(tmp_path)
|
|
original_socket_send = own.send_json
|
|
original_batch_send = hub.send_vmix_batch
|
|
lost = []
|
|
|
|
async def lose_one_ack(message):
|
|
if any(command.get("SelectedName") == "Field10.Text" for command in frame_commands(message)) and not lost:
|
|
own.frames.append(message)
|
|
lost.append(message)
|
|
return # Agent read the frame; its ACK was lost.
|
|
await original_socket_send(message)
|
|
|
|
async def short_timeout(device_id, *, assignment_id, match_id, commands, timeout=4.0):
|
|
return await original_batch_send(device_id, assignment_id=assignment_id,
|
|
match_id=match_id, commands=commands, timeout=0.01)
|
|
|
|
own.send_json = lose_one_ack
|
|
hub.send_vmix_batch = short_timeout
|
|
rows = [_entry(i, "One Input") for i in range(35)]
|
|
|
|
async def scenario():
|
|
result = await hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
|
|
match_id="g", entries=rows, use_batch=True)
|
|
assert all(item["ok"] for item in result["results"])
|
|
lost_names = [command["SelectedName"] for command in frame_commands(lost[0])]
|
|
assert len(lost) == 1 and len(lost_names) == 6
|
|
assert result["retries"] == len(lost_names)
|
|
names = [command["SelectedName"] for command in sent_commands(own)]
|
|
assert names == [f"Field{i}.Text" for i in range(35)] + lost_names
|
|
assert not hub._pending_commands and other.frames == []
|
|
before = list(result["results"])
|
|
await own.acknowledge(lost[0])
|
|
assert result["results"] == before and not hub._pending_commands
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_mapping_transport_retries_only_the_isolated_failed_command(tmp_path: Path):
|
|
hub, own, other = transport(tmp_path)
|
|
original = own.send_json
|
|
attempts = {}
|
|
|
|
async def fail_one_once(message):
|
|
commands = frame_commands(message)
|
|
for command in commands:
|
|
name = command["SelectedName"]
|
|
attempts[name] = attempts.get(name, 0) + 1
|
|
if any(command["SelectedName"] == "Field3.Text" and attempts["Field3.Text"] == 1 for command in commands):
|
|
own.frames.append(message)
|
|
await own.acknowledge(message, results=[{"ok": command["SelectedName"] != "Field3.Text",
|
|
"reason": "HTTP 500" if command["SelectedName"] == "Field3.Text" else ""} for command in commands])
|
|
return
|
|
await original(message)
|
|
|
|
own.send_json = fail_one_once
|
|
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
|
|
match_id="g", entries=[_entry(i, "Score") for i in range(8)], use_batch=True))
|
|
assert all(item["ok"] for item in result["results"])
|
|
assert result["retries"] == 1 and attempts["Field3.Text"] == 2
|
|
assert all(count == 1 for name, count in attempts.items() if name != "Field3.Text")
|
|
assert [command["SelectedName"] for command in sent_commands(own)] == [
|
|
"Field0.Text", "Field1.Text", "Field2.Text", "Field3.Text",
|
|
"Field4.Text", "Field5.Text", "Field6.Text", "Field7.Text", "Field3.Text"]
|
|
assert not hub._pending_commands and other.frames == []
|
|
|
|
|
|
def test_three_bad_prefix_fields_do_not_starve_healthy_suffix_or_flood_retries(tmp_path: Path):
|
|
hub, own, other = transport(tmp_path)
|
|
original = own.send_json
|
|
|
|
async def reject_values(message):
|
|
commands = frame_commands(message)
|
|
if not commands:
|
|
return await original(message)
|
|
own.frames.append(message)
|
|
await own.acknowledge(message, results=[{"ok": command["SelectedName"] not in
|
|
{"Field0.Text", "Field1.Text", "Field2.Text"}, "reason": "HTTP 500" if
|
|
command["SelectedName"] in {"Field0.Text", "Field1.Text", "Field2.Text"} else ""}
|
|
for command in commands])
|
|
|
|
own.send_json = reject_values
|
|
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
|
|
match_id="g", entries=[_entry(i, "Score") for i in range(200)], use_batch=True))
|
|
assert len(result["results"]) == 200
|
|
assert [item["ok"] for item in result["results"]] == [False] * 3 + [True] * 197
|
|
assert result["retries"] == 3
|
|
names = [command["SelectedName"] for command in sent_commands(own)]
|
|
assert names == [f"Field{i}.Text" for i in range(200)] + ["Field0.Text", "Field1.Text", "Field2.Text"]
|
|
assert all(len(frame_commands(frame)) <= 6 for frame in own.frames)
|
|
assert 0 <= result["retries"] <= 12
|
|
assert other.frames == [] and not hub._pending_commands
|
|
|
|
|
|
def test_mapping_diagnostics_report_actual_retry_without_claiming_unconfirmed_delivery(tmp_path: Path):
|
|
hub, own, _ = transport(tmp_path)
|
|
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
|
|
match_id="g", entries=[_entry(i, "Score") for i in range(2)], use_batch=False))
|
|
assert result["ack_waited"] is True and result["transport"] == "sequential_ack"
|
|
root = Path(__file__).resolve().parents[1]
|
|
admin = (root / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
|
|
_run_functions(admin, ["mappingApplyTransportSummary"], r'''
|
|
const summary = mappingApplyTransportSummary({transport:'sequential_ack', input_groups:1,
|
|
mapping_commands_sent:2, batch_retries:1, batch_chunks_total:0, batch_chunks_applied:0});
|
|
assert.ok(summary.includes('Input: 1'));
|
|
assert.ok(summary.includes('повторов 1'));
|
|
assert.ok(!summary.includes('без ожидания ACK'));
|
|
assert.ok(!summary.includes('пакетов'));
|
|
''')
|
|
batched = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
|
|
match_id="g", entries=[_entry(i, "Score") for i in range(2)], use_batch=True))
|
|
assert batched["transport"] == "bounded_batch_ack" and batched["ack_waited"] is True
|
|
assert batched["chunks_total"] == batched["chunks_ok"] == 1 and batched["chunks_failed"] == 0
|
|
_run_functions(admin, ["mappingApplyTransportSummary"], r'''
|
|
const summary = mappingApplyTransportSummary({transport:'bounded_batch_ack', input_groups:1,
|
|
mapping_commands_sent:2, batch_retries:0, batch_chunks_total:1, batch_chunks_applied:1});
|
|
assert.ok(summary.includes('Input: 1'));
|
|
assert.ok(summary.includes('пакетов 1/1'));
|
|
assert.ok(!summary.includes('без ожидания ACK'));
|
|
''')
|