Run the test suite in parallel: 19.8s and 34 failures to 10.3s green #10
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/async-test-suite"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Takes the suite from 19.8 s with 34 failures to 10.3 s green (4914 passing, 6 skipped).
mix precommitexits 0; nine consecutive--repeat-until-failureruns and five seeds (1, 7, 424242, 999983, 31337) pass.The 34 failures
reset_test_state/0was gated ontags[:test_group] == :scores_dirwhile no module declared that group, so the score-tree and cache wipe never fired and every empty-state assertion read a previous module's files. The predicate is nowtags[:test_group] == :scores_dir or tags[:async] != true— group members are serialized against each other, and sync modules run only after every async module finishes, so both branches are race-free. 23 modules touching the shared:propagation_scores_dirtree,ScoreCache,GridCache,NexradCacheorMicrowaveprop.Cachenow declaregroup: :scores_dir.Async triage
58 modules flipped to
async: true, 23 into the group, 47 stay sync — each with a one-clause comment above itsuseline naming the actual VM-global (a config key production reads, aLoggerhandler,:persistent_term, a named singleton/ETS table,File.cd!, a sandbox mode change, a telemetry handler under a constant id). Without that comment nobody can tell a deliberate sync file from an accident.Two live-network paths, found by measurement
rover/compute_test.exsrestored:rover_road_proximity_enabledtotruein itsafterblocks whileconfig/test.exssets itfalse. After that module ran, every later rover compute took theRoadProximitypath and dialled Overpass with an 8 s deadline. A serial bisect in an isolated tree: 270 files = 14.8 s, the same 270 minusrover_live_test.exs= 7.0 s.Closed at three levels: no test writes the key (
Compute.run/2grew adeps[:road_enabled?]seam),RoadProximitythreads*_req_optionsso a future leak fails instantly as "no stub found" instead of live HTTP, andNceiMetarClientplusHrrrNativeGridWorker's bypassingReq.getgot the same treatment.The Ecto sandbox does not isolate PubSub
BeaconLive.Indexsubscribes to the global beacons topic andstream_inserts the broadcast payload, soMonitorLive.ShowTest's beacon rendered into the page under test and broke a page-global selector about one run in five. A probe confirmed the test's own connection saw only its own row — no commit leak. Fixed with unique fixture identities and identity-scoped selectors, then swept across the LiveView suite (topic → LiveView → handler map in the diff).Detached tasks
Weather.Grid's cold grid fill and scalar materialize both ran underTask.start/1: no$callers, so their reads escaped the sandbox; they outlived the spawning test, wrote into the score tree after it had been wiped (... materialize failed ... :enoent), and broadcastweather:updatedinto concurrent LiveViews. Both now sit behind:weather_async_grid_fill— defaulttrue, off in test.Test quality
try/rescue, so neither real assertion ever ran; now two deterministic tests seeding three lattice cells.NotifyListenerinvalidatesScoreCachebefore broadcasting); without itpoint_forecast/3served a stale cached score.Mix.Tasks.UnusedTest720 ms → ~20 ms: a three-beam fixture dir instead of decoding all 384_buildbeams.Application.fetch_env/2. The truthiness check hadband_weights_testdeleting a legitimately-false:band_weights_json, making every later module load the realpriv/algo/band_weights.json.Infrastructure
max_casesfromEXUNIT_MAX_CASESelseschedulers_online() * 2(was pinned to 2); Repo pool sizedmax_cases + 4inruntime.exsrather than baked into_buildat compile time, with thepool_size >= max_casesinvariant documented;Sandbox.mode(Repo, :manual)pinned before tests start;Microwaveprop.Cachememoization off in test;scripts/measure_test_suite.shrecords both passes and tolerates a red run so rankings still land.AGENTS.mddocuments the whole model.Every production seam added defaults to today's behaviour when its config key is absent, so production is unchanged — which is also why there is no
CHANGELOG.mdentry: nothing here is observable to a site visitor.🤖 Skippy PR review
2 findings — 1 needing attention before merge.
test/support/data_case.ex:48async: truemodules still touch the shared score tree / ScoreCache the group now writes concurrentlylib/microwaveprop/workers/hrrr_native_grid_worker.ex:195:hrrr_native_http_gethas no test-env default, so this path can still reach NOMADSReviewed
a7becc34f492. Commentskippy reviewto re-run.@ -191,2 +193,3 @@# the .idx fetch instead of hitting the live NOMADS bucket.defp fetch_idx(url) docase Req.get(url, receive_timeout: 120_000) dorunner = Application.get_env(:microwaveprop, :hrrr_native_http_get, &Req.get/2)🟡 Warning —
:hrrr_native_http_gethas no test-env default, so this path can still reach NOMADSThe comment promises "a test that reaches this branch stubs the .idx fetch instead of hitting the live NOMADS bucket", but the runner falls back to
&Req.get/2andconfig/test.exsnever sets:hrrr_native_http_get— only individual tests do, viaApplication.put_envinHrrrNativeClientTest. Every other client in this PR got a config-level guard (ncei_metar_req_options,rover_road_proximity_req_options, …); this one has the seam but not the belt-and-braces.HrrrNativeGridWorkerTestis nowasync: true, so the first test that does reachfetch_idx/1spends 120 s of live HTTP inside the concurrent phase — the exact class of leak this PR was built to close.Add to
config/test.exs:@ -25,2 +46,3 @@_owner = Microwaveprop.DataCase.setup_sandbox(tags)Microwaveprop.DataCase.reset_test_state()if tags[:test_group] == @propagation_group or tags[:async] != true do🟠 High — Three plain
async: truemodules still touch the shared score tree / ScoreCache the group now writes concurrentlyThe gate is right, but the sweep missed three modules, and this PR is what makes them race: the heavy writers (
PropagationTest,PathLiveTest,MapLiveTest,ScoresControllerTest,RoverLiveTest, …) used to beasync: false, so they only ran after every async module had finished. Now they areasync: true, group: :scores_dirand run concurrently with every non-group async module.Still plain
async: true, no group:test/microwaveprop/propagation/untested_functions_test.exs— asserts the empty state of the shared tree (latest_valid_time() == nil,available_valid_times/1 == [],scores_at/3 == [],latest_scores/2 == [],point_forecast/3nil,point_detail/4nil). All of those go throughScoresFile.list_valid_times/1on:propagation_scores_dir, so any concurrent group member'sreplace_scores/2makes them non-empty. This is exactly the failure mode the PR describes, just relocated.test/microwaveprop_web/live/contact_live/show_coverage_test.exs:77,792andshow_hydration_test.exs:172,340—ScoreCache.clear/0insetup, i.e. a global ETS wipe from a plain async module, which the newreset_test_state/0docs explicitly forbid. Concretely it breaksPropagationTest"cache-hit path does not emit the scores_at.stop span": that test doesScoreCache.put/3then asserts%{hit: true}; a clear landing in between turns it into a miss and fails both the assert and therefute_received.Fix: give the first
group: :scores_dir(it only reads), and give the two ConnCase modulesgroup: :scores_diras well so theirScoreCache.clear/0is serialized against the other members.Group annotations. Three modules read or mutate the state the `:scores_dir` group now writes concurrently, and were still plain `async: true`: * propagation/untested_functions_test.exs asserts the empty state of the shared tree -- latest_valid_time/0, available_valid_times/1, scores_at/3, latest_scores/2, point_forecast/3 and point_detail/4 all reach ScoresFile.list_valid_times/1, so any group member's replace_scores/2 makes them non-empty. * contact_live/show_coverage_test.exs and show_hydration_test.exs call ScoreCache.clear/0 from setup -- a wipe of the VM-global ETS table that PropagationTest's cache-hit span test seeds and asserts %{hit: true} against. All three now declare `group: :scores_dir`. Before this PR they were safe only because the heavy writers were `async: false` and therefore ran after every async module had finished. Native HRRR idx fetch. The `:hrrr_native_http_get` seam defaults to `&Req.get/2` and only HrrrNativeClientTest replaced it, so any other test reaching that branch dialled the live NOMADS bucket with a 120 s receive_timeout. Both call sites now merge `:hrrr_native_req_options`, which config/test.exs points at a Req.Test plug -- the same belt-and-braces convention as hrrr/hrdps/gefs/nexrad/ncei_metar. That leak was real and load-bearing: with the plug in place, Mix.Tasks.SimpleTasksTest's HrrrNativeBackfill test immediately raised "no mock or stub" from fetch_idx/1 -- it had been downloading a real .idx through inline Oban on every run. Stubbed with a 404 (the task's contract is the enqueue, not the download), suite wall time went 10.3s -> 7.7s. Also records why contact_edit_test's fixed-atom Microwaveprop.Cache keys need no group: memoization is off in test so production never writes them, and the only other toucher deletes rather than seeds. Six consecutive --repeat-until-failure runs green at 7.7s; precommit clean.Both correct, both fixed in
d6e1b746.Three ungrouped shared-state modules. Verified and grouped:
propagation/untested_functions_test.exs— plainasync: true, and every assertion is an empty-state read reachingScoresFile.list_valid_times/1(lines 25, 31–32, 44, 50, 55, 61, 67, 72, 78, 103, 108). Nowgroup: :scores_dir.show_coverage_test.exs(ScoreCache.clear/0at :77, :792) andshow_hydration_test.exs(:172, :340) — both nowgroup: :scores_dir, so the ETS wipe is serialized against the members that seed it.Your framing is the important part: these were safe only because the heavy writers were
async: falseand therefore ran after every async module. Flipping them into the group is what turned latent into live, and the sweep missed these three.While in there I swept all 247 plain-async modules for
ScoreCache|GridCache|NexradCache|Microwaveprop.Cache|propagation_scores_dir|replace_scores|ScoresFile|ProfilesFile|ScalarFile. Four other hits, all genuinely fine:file_prune_testbuilds its ownSystem.tmp_dir!path,scores_file_property_testcallsextract_points/2on an in-memory payload,weather_layers_testonly namesProfilesFile.read/1in a comment, andcontact_edit_testseeds three fixed-atomMicrowaveprop.Cachekeys — that last one now carries a comment saying why it needs no group (memoization is off in test, so production never writes those keys, and the only other toucher deletes rather than seeds).:hrrr_native_http_get. Right diagnosis, and worse than latent — it was firing on every run. Both call sites (hrrr_native_client.ex:206,hrrr_native_grid_worker.ex:194) now merge:hrrr_native_req_options, andconfig/test.exspoints that at{Req.Test, Microwaveprop.Weather.HrrrNativeClient}, matching thehrrr_/hrdps_/gefs_/nexrad_/ncei_metar_convention. I went with request options rather than a function default so the guard sits on the same axis as every other client, and so the existingApplication.put_envtransport override inHrrrNativeClientTeststill wins.With the plug in place the suite went red immediately:
That test had been downloading a real
.idxfrom NOMADS through inline Oban on every single run. Stubbed with a 404 — the task's contract is the enqueue, not the download.Suite wall time 10.3 s → 7.7 s as a result. Six consecutive
--repeat-until-failureruns green,mix precommitexit 0.Resolved both earlier findings — the three ungrouped shared-state modules (
untested_functions_test,show_coverage_test,show_hydration_test) now declaregroup: :scores_dir, and:hrrr_native_req_optionsgives the default&Req.get/2runner aReq.Testplug inconfig/test.exsso an unstubbed .idx fetch fails instantly instead of burning the 120 s receive_timeout on NOMADS.0 still open. Nothing new in
a7becc34f492..d6e1b746518a— the merge order in bothhttp_get/2andfetch_idx/1leaves prod behaviour unchanged (empty options list), and thesimple_tasks_teststub is registered in the same process that runs the inline Oban job.Reviewed
d6e1b746518a.