Repository navigation
research: soak recorder for the live push sources (S2) - #204
Merged
Merged
Conversation
scripts/soak_live.py runs the live service as it is (LiveService with the direct or page source, the arbiter, the poll source) for hours and records push frames, connection events, leader switches, REST reads with the frame-to-event-read latency, the per-minute fallback reads the owner set for the c project, and the memory of this process and of each browser. Kills of the connection or the pages test the switch back. Nothing it writes holds the credential, the CONNECT body, server addresses, the INFO body or an IP address.
A fake NATS server, a fake page and a fake API drive the recorder: frames and event-read latency, kill and switch back for both sources, the per-minute fallback share and ranking, the budget, and a privacy scan of a recorded run. The script's research profile joins the settings check.
…cklog The owner's revised fallback rule for the c project: while the list leads, matches in a joined set are read every 60 s, other live matches as the rate allows, longest unread first; reads that do not fit are skipped and counted per sport. The joined set comes from --joined-ids and from --joined-random-soccer (20 random live football matches by default, replaced when they finish). The summary reports read-gap percentiles for joined and other matches, the list rate achieved and the skip rates.
…ids shape
--rate-schedule "60:1,120:5" runs one rate after another in a single run.
requests.jsonl records each minute's requests by kind and path, the peak
in any one-second window, 403s (curl challenge, browser challenge,
browser), 429s and Turnstile solves counted from the client's own log
lines, and latency p50/p95; the summary gives the same per rate phase.
--joined-ids now reads c's message shape {"ids": [...]} and ignores a
malformed file or one with more than 50 000 ids, as c does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item S2: a soak recorder for the live push path: it measures the live service as it is over hours. Research tooling only; no product code changes. Nothing in this PR contacts SofaScore: all tests run offline.
Usage
python scripts/soak_live.py --sports football,basketball,tennis --hours 6 \ [--source direct|page] [--run-id ID] [--max-requests N] [--rate 1.0] [--list-share 0.5] \ [--joined-ids FILE] [--joined-random-soccer 20] [--kill-every MIN] [--kill-at MIN] \ [--compare-credential-sports] [--out DIR]research/soak/<run-id>/. The run refuses a run id it has already used. A summary is written every 10 minutes and at the end.~/.cache/sofascore_research/soak_store/<run-id>. The throttle dir defaults to~/.cache/sofascore_research/throttleand the browser profile ischrome_soak. The live profile ischrome_soak-live.--max-requestsdefaults tohours x 3600 x rate, so it averages at most 1 req/s. With--rate-schedule, the default is the sum of the phases. Every REST read is paced at--rate, then takes the shared research lock. Once the budget is spent, no more REST reads are made, but push recording continues.What runs
It is the service as it is.
RecordingLiveService(aLiveServicesubclass) runs with the chosen push source, the real arbiter and the realPollSource. Its scope covers every live match of each sport, so the poll source's rounds work exactly as they do today. Its confirmation read is turned off, because the recorder makes that read instead. The hooks are:DirectConnectionsubclasspage), which closes the pagesPushFeed_systemand fetchWhat is recorded
frames.jsonl: one row per MSG frame. Each row has receive time (wall and monotonic), sport, event id, the changed paths as keys only, values forstatus.code/status.typeand score fields only,changes.changeTimestamp, and the triggers.connection.jsonl:-ERR, with the known NATS text only--compare-credential-sports: the result for other sports' pages (same/different only)page: page open and gone, and INFO/PING/PONG/-ERR per page socketleader.jsonl: arbiter switches (from, to, reason) with fallback start/stop, plus blocked/recovered.reads.jsonl: every REST read, with who made it (service, recorder or fallback), kind, event id, status, latency, and live count for lists. For recorder reads it also has the trigger (kinds, source frame/list, time),trigger_to_read_s, and whether the read reflects the change. A CDN-stale read is retried afterCONFIRM_RETRY_SECONDS, up toCONFIRM_ATTEMPTS.requests.jsonl: one row per minute, aligned to the run start. Each row has:403, 429 and Turnstile are counted from the client's own log lines (the
UtilsandChallengeSolverloggers); the text of those lines is never written. While the counter is installed, only WARNING and above go on to the root handlers.fallback.jsonl: one row per minute and sport while the list leads. Each row has live matches, joined live, reads made (joined, listed, other), skipped (joined, listed) and the cut reason. It also records joined-set changes (join, leave).resources.jsonl: every 30 s, and every 5 s during a credential read. It has RSS of this process, the main bridge's browser, the live profile's browser and other children, plus the total and the socket count. Browsers are told apart by their--user-data-dir, which is never written.summary.json, per sport:Overall, it also has connection stats, requests by who/kind/status, peak RSS by bucket, and notes. One note states that this is the first live run of the shipped stdlib WebSocket client; the 2026-10-01 probe used aiohttp.
Fallback rule (owner's rule)
While the list leads (push down, silent or killed), the arbiter lists every 30 s and the poll source makes its own near-end, dropped, stuck and overdue reads. These are unchanged. On top of that:
joined.listed.60 x --rate x (1 - --list-share)reads; the rest of the rate is kept for discovery and list reads. A read that does not fit its minute is skipped and counted per sport. The queue is rebuilt from the latest list every minute, so it never grows without bound.--joined-ids FILE(shape{"ids": [...]}; a malformed file or one with more than 50 000 ids is ignored with a warning) plus--joined-random-soccer N(default 20). The random part picks N random live football matches at the start and replaces each one when it leaves the live list. There is no external "joined" signal here.The summary adds:
The product's
poll_sourceis unchanged.Rate phases
--rate-schedule "60:1,120:5"gives 60 minutes at 1 req/s, then 5 req/s; the last phase lasts until the end of the run. The current rate sets both the pacing and each minute's fallback share.In a phase above 1 req/s, the 1 req/s research lock is not taken. If the schedule goes above the app's
client.rate(5/s by default),client.rateis raised to match, unless it is set explicitly.summary.jsonhas arate_phasesentry per phase with the same statistics as the minute rows, plus the rate achieved.How the kill works
direct: the recorder callsshutdown(SHUT_RDWR)on the current socket. The client sees an ordinary drop. The feed closes, so the arbiter switchesdirect>poll(push_disconnected) and runs a poll round. The client reconnects and subscribes again, the feed opens, and the arbiter switches backpoll>direct.page: the recorder closes the sport pages. The page'scloseevent makes the feed go away (push_unavailable).PageSourcereopens the page after its back-off, and the arbiter switches back.connection.jsonl(kill, and the session end or page close carrying the cause), inleader.jsonl, and in the summary downtime windows.Privacy
Every written row passes through
sofascore_scraper/redact.py, which also covers the runtime credential, and through the researchredact()(IPs, geo keys).Key names avoid the redactor's secret-key pattern, so values like "different" are not masked.
test_a_recorded_run_holds_no_credential_address_or_info_bodyand the page test scan a recorded run. They check that there is:user/pass/auth_tokenkeyCONNECT {, INFO fields orclient_ip***(nothing was ever about to leak)The same tests also run
test_research_privacy.scanover the run.Tests
tests/test_soak_live.pyhas 27 tests. It uses the in-process fake NATS server and recorded frames fromtest_live_direct_source.py, the fake page fromtest_live_push_source.py, andFakeApi.test_research_settings.pynow includes the script's profile. The full suite passed withSTORE_SHADOW_CHECK=1: 9807 passed, 39 skipped.ruff check .is clean.