Files
hockey_new/tests/test_build97_mapping_chunked_transport.py

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'));
''')