fix(backtest,multi-vm): handle MT5 relaunch, per-VM healthchecks, orphan sweep - #12
Conversation
…han sweep Five production bug fixes found while investigating why backtests reported as failed on terminals that had in fact run them, and why the container healthcheck was permanently red and hiding real outages. Backtest reliability - MT5 relaunches itself to apply a LiveUpdate: the launcher process exits 0 before the replacement terminal writes its report, so the job was failed as "Report not generated" while a valid report landed minutes later. Wait for the replacement process and its run, bounded by the existing job timeout; a run long enough to be a real backtest is not an update restart and still fails immediately. - INI values are treated as literals (RawConfigParser) on both read and write. ConfigParser interpolation rejected bare '%' characters in symbols, report names and percentage inputs, surfacing as a spurious 400. Multi-VM healthchecks - check_health.py probed every port in config.yaml, so on a multi-VM install each VM reported the other VM's terminals DOWN and the container sat permanently unhealthy, making a genuine failure indistinguishable from standing noise. Apply the same per-VM group filter the launcher uses, with a fallback to no-filter if the import fails. - healthcheck.sh had the same bug: it grepped every port in config.yaml. Filter by the per-VM group file already bind-mounted by docker-compose, using awk (no python in the alpine container). No group file means no filter, preserving single-VM behaviour. Startup performance - sweep_orphans() parsed every *.json in the shared backtest-jobs dir at startup (19k+ files, ~2.5 min per process, many processes at boot) just to mark in-flight jobs failed. Inspect only state files touched within BACKTEST_SWEEP_LOOKBACK using scandir; add prune_old_jobs() to retire completed/failed jobs older than BACKTEST_JOB_RETENTION, gated by a shared marker so only one process scans per interval, in a background daemon thread so boot never blocks. Atomic tmp+rename writes prevent concurrent sweepers from seeing truncated files. Tests updated to cover relaunch handling, INI literals, per-VM healthcheck filtering and job sweep/prune; unit suite passes.
2290ead to
5754311
Compare
psyb0t
left a comment
There was a problem hiding this comment.
Approving. Nice work — the diff is ~1:1 code to tests, and the comments explain
why rather than restating the code, which made this reviewable.
Verified rather than eyeballed:
- All four suites green on the PR head:
make lint,make test-unit(244
passed),make test-integration, andmake test-go(the Go client is
byte-identical to master, so it's unaffected). - Mutation-tested the fixes — each one broken on purpose fails the test that
claims to cover it, so the tests aren't decorative:- relaunch wait disabled →
test_fast_clean_exit_without_report_waits_for_a_relaunch RawConfigParserback toConfigParser→ both INI tests, with exactly
invalid interpolation syntax in 'EUR%USD'- sweep mtime filter disabled →
test_sweep_skips_stale_filesand
test_sweep_honors_custom_lookback
- relaunch wait disabled →
parse_duration_to_secondsis shared — it also parsesutc_offset, the
wickworks timeout, and the user-supplied backtest timeout — so adding a unit
to it deserved proof rather than reasoning. Diffed the old and new parser
over 50 inputs: 42 identical, 8 additive (the newdforms), 0
regressions.- Ran the new
healthcheck.shawk against a real multi-terminal config: no
group file → all ports; a two-entry group → exactly those two; an empty
group file → falls back to all (the case you called out in the comment);
missing file → falls back to all; and instance clones sharing a
broker/account are distinguished correctly.
Four follow-ups, none blocking — happy for these to be a separate PR:
-
The two health-filter tests assert source text, not behaviour.
test_check_health_probes_only_this_vms_terminalschecks
"_in_group(t, allowed)" in src, and
test_container_healthcheck_probes_only_this_vms_portsasserts an exact awk
line,'if (!have_group) { print port; return }' in src. They catch
deletion but would pass on a filter that is present and wrong, and a
whitespace-only reformat breaks them while the behaviour is fine. Running
the awk against a fixture config plus a fixture group file and asserting the
emitted ports is only a few lines and covers the thing that actually
matters. This is the one place where the rest of the PR's test quality
dips. -
_remove_job_statebuilds a path from thejobIdread out of the state
file, thenshutil.rmtrees it. Safe today becausejobIdis always a
server-generateduuid.uuid4().hex, but it is an unbounded delete keyed on
a value read back from a directory several processes write to. Rejecting
anything that isn't 32 hex chars before the join would make that
independent of the writer. -
The
ImportErrorfallback incheck_health.pysilently disables the
filter, which reinstates exactly the permanently-unhealthy behaviour this
PR fixes — with nothing in the output to say so. A single line on that path
would make the degradation visible instead of looking like normal
operation. -
The 24h sweep lookback changes recovery semantics. A job left in-flight
by an outage longer thanBACKTEST_SWEEP_LOOKBACKis no longer marked
failed — it staysrunningfor good, where the old full scan caught it.
Realistically the caller has given up by then and the window is
configurable, but it's a behaviour change worth a line in the docs.
Separately, two pre-existing issues surfaced while reading this — both predate
the PR, no action needed here, just flagging them:
parse_duration_to_seconds("inf")(also"1e400") passes the bare-number
fast path and then raisesOverflowErrorfromint(round(inf * 3600)). The
submission handler only catchesValueError, so it escapes as a 500 rather
than the intended 400. Reachable through thetimeoutform field.timeout_secondsis never clamped, so a request can set an arbitrarily long
timeout and holdRUN_LOCKon that terminal for as long as it likes.
|
Merged — thanks a lot for this one. 🙏 Genuinely good work, and I want to name the specific things rather than just say
The follow-ups in my review are all small and none of them held this up. The Cheers. |
Five bug fixes that surfaced while running backtests on a multi-terminal deployment: backtests on terminals that had in fact run them were being reported as failed, and the container healthcheck was permanently red, masking a genuine outage.
What and why
Backtest reliability
%characters in symbols, report names and percentage inputs, causing a spurious 400 on submission.RawConfigParseris now used on both the read and write sides, matching how MT5 treats these values.Multi-VM healthchecks
check_health.pyprobed every port inconfig.yaml. On a multi-VM install each VM reported the other VM's terminals DOWN and the container sat permanently unhealthy, making a genuine failure indistinguishable from standing noise (observed: a 25,000+ failing streak while all local terminals were serving). It now applies the same per-VM group filter the launcher uses, with a fallback to no-filter if the import fails.healthcheck.shhad the same bug — it grepped everyport:inconfig.yaml. It now filters by the per-VM group file docker-compose already bind-mounts, using awk since the container image has no python. No group file means no filter, preserving single-VM behaviour.Startup performance
sweep_orphans()parsed every job file at boot. On a large shared backtest-jobs dir this took ~2.5 minutes per process, multiplied by the number of processes starting at boot — all to mark in-flight jobs failed. It now inspects only state files touched withinBACKTEST_SWEEP_LOOKBACK, and a newprune_old_jobs()retires completed/failed jobs older thanBACKTEST_JOB_RETENTION, gated by a shared marker so only one process scans per interval. Both run in a background daemon thread so boot never blocks, and writes are atomic (tmp+rename) so concurrent sweepers never see truncated files.Testing