Code review - #47
Merged
Merged
Conversation
See CHANGELOG.md for details. Includes a BREAKING change: the mwax_subfile_distributor web service endpoints which change state are now POST-only, so external callers (M&C, FREDDA) must be updated before this is deployed.
See CHANGELOG.md. Note a small behaviour change: utils.call_webservice()'s max_retries now means exactly that many attempts rather than max_retries + 1.
A worker thread calling sys.exit() only kills that thread and discards the exit code, so a redis failure stopped subfile distribution while logging "Completed Successfully" and exiting 0. Worker code now signals the main thread via request_fatal_shutdown(), which exits with the intended code. Also clears the ResourceWarnings from the test suite. See CHANGELOG.md.
Runs ruff format --check, ty check and pytest on every PR and on pushes to main, against Python 3.12 and 3.13. ruff check runs but is not yet a gate, as there are ~86 pre-existing findings. See CHANGELOG.md.
Purely mechanical line wrapping of the 60 lines ruff format cannot fix, since it will not split a string literal. Verified content-preserving by comparing every file's AST with string whitespace normalised. ruff check is down from 86 findings to 26. See CHANGELOG.md.
Fixes docstrings that contradicted the code they described, adds docstrings to the 13 public classes/functions that had none, corrects the README Module Reference and documents the six previously-undocumented CLI tools, renames the CALVIN.md phase-fit illustrations to match the current step numbering, and fixes a batch of typos. No executable code changed apart from renaming two functions in make_illustrations.py. See CHANGELOG.md.
… batches so that new fits get uploaded quickly if there is a big backlog.
…and will not try and finish all paths when told to exit
…gain_outlier plots
…ot allocate memory
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.
1.10.2 26-Aug-2026
1.10.1 26-Aug-2026
[Errno 12] Cannot allocate memoryon a 256-tile picket fence (seen viacal_utils).plot_outlier_gains()created itsProcessPoolExecutorwith nomax_workers, so it spawnedos.cpu_count()workers, and a stitched page peaks at ~322MB measured (10800x3600px for thecal_utilsdefault of 16 tiles per page: a 156MB raw RGBA buffer, roughly doubled becausesavefig(bbox_inches="tight")renders once to measure the bounding box and again to write the file). On a many-core calvin node that was tens of GB of live render buffers. The pool is now bounded by_max_render_workers(), which takes the smallest of the page count, the CPU count, and how many page-sized allocations fit in a fraction of the memory actually available. Memory detection checks the cgroup limit before the node'sMemAvailable, since these run as Slurm jobs and the node can have hundreds of GB free while the job is confined to a fraction of it; if neither can be read it falls back to 4 workers rather than assuming plenty. Page geometry now comes from a shared_page_grid()helper used by both the renderer and the estimator, so the memory estimate cannot drift from the figure actually created.plot_outlier_gains()also takes an optionalmax_workersoverride. Note the per-worker bundle copy was measured at only ~5% of the per-page peak (16.5MB against 322MB), so slicing the bundle per page was deliberately not done -- it would have meant threading a tile offset through the renderer for a negligible saving.tests/data/1391522232, 24 pickets x 32 chanblocks, vstests/data/1094488624, 1 file, both 768 total chanblocks): the group-level stages are not picket-sensitive at all -- 127.6s vs 126.1s, of which ~98% is phase fitting, which already runs on the whole group's concatenated chanblocks. The 3-5x runtime gap was entirely in the per-file loops inprocess_solutions().plot_outlier_gains()was the bulk of that gap: it was called once per solution file, so a 24-file observation created 24ProcessPoolExecutors and rendered 120 pages against 4 for a contiguous observation, for the same number of data points (4 of 24 files measured at 223s, i.e. ~22 minutes extrapolated, vs 56s contiguous). It now renders one paginated set for the whole observation from a single pool: 5 pages, 22.4MB, 186s single-core measured. Outlier detection is untouched and remains strictly per file -- a polynomial fit across a picket-fence frequency gap would be meaningless -- so only presentation changed.STITCH_GAP_CHANBLOCKS) with a dashed break line at every boundary, and ticks are labelled with the real coarse channel number. Plotting against true frequency was rejected: 1391522232 spans 78.7-241.2 MHz with only 18.9% of that span covered by data, so over 80% of the axis would have been empty. A real broken axis (one subplot per segment) was rejected too -- ~1500 axes per page would have cost more than the per-file plotting it replaced. Stitched pages use wider subplots on a narrower grid (STITCHED_SUBPLOT_WIDTH_IN/STITCHED_TILE_COLS), since 768 chanblocks at the single-file width gave only ~38px per picket; single-file observations keep the previous layout unchanged. Note two behaviour changes: output filenames no longer carry a_ch<N>component (generate_plot_index_filematches on thegain_outliers_tilessubstring, so the index still categorises them correctly), and the per-tile "% Good" summary is now counted across the whole observation rather than per picket.axvspanshading in the gain-outlier plots replaced with a single maskedfill_between(..., step="mid")per axis. Equivalent output, but with every file's chanblocks now on one axis the old per-channel loop could have added tens of thousands ofRectanglepatches to a single page.generate_hyperdrive_plots()is now driven bygenerate_hyperdrive_plots_for_files(), which runs the per-file invocations concurrently on a thread pool (each is an external hyperdrive process, so the GIL is released while waiting on the child). A picket fence previously did 48 serial process launches -- 24 for the "before" pass and 24 for "after" -- against 2 for a contiguous observation. Failures are collected and returned rather than raised, so one bad file neither aborts the rest nor fails an otherwise good calibration.generate_hyperdrive_plots(), which globbed the whole output directory for*_solutions_amps.png. It was only correct serially (an already-renamed file stops matching), cost a full directory scan per file (O(N^2) for a picket fence), and would have had concurrent calls renaming each other's output. It is now scoped to its own input file's stem -- still a glob rather than hardcoded_amps/_phasessuffixes, so a hyperdrive version emitting a different plot type still gets its "before" copy protected, and it now warns if a run that reported success produced no matching plots.HyperfitsSolution.resultsnow caches the RESULTS HDU instead of reopening the file on every access, andHyperfitsSolutionGroup.resultsno longer reads each file twice per access (once in the length-validation loop, once in the concatenate).weightsgoes through that several times per pipeline run, so a 24-file observation was opening solution files 192 times per run against 8 for a contiguous one, over a shared filesystem. A missing RESULTS HDU is cached as missing, so it keeps raisingKeyErrorfor the uniform-weights fallback without re-reading. Safe becausewrite_jones()only rewrites the SOLUTIONS HDU -- there is a regression test pinning that.1.10.0 26-Aug-2026
Breaking changes
/shutdown,/pause_archiving,/resume_archiving,/dump_voltages,/release_cal_obs) are now POST only; they previously accepted GET as well, meaning anything that speculatively fetched a URL could stop the correlator or dump the voltage buffer./statusstill answers GET. In-repo callers have been updated (calvin_processornow POSTs to/release_cal_obs), but external callers must be updated too: the M&C system calls/pause_archiving,/resume_archivingand/dump_voltages, and FREDDA triggers/dump_voltages. Deploy the new mwax_subfile_distributor only once those callers use POST, or those calls will start returning405 Method Not Allowed.utils.call_webservice()gained amethodparameter to support this.Bug fixes
Fixed a number of bugs found during code review:
{obs_id}_residual.png-- the phase-residual polynomial fit was including zero-weight (flagged) channels.np.logical_andis a binary ufunc, so the third positional argument was being taken asout=rather than as a third condition, silently discarding theweights > 0filter.HyperfitsSolutionGroup.weightsreturned an array sized from only the FIRST solution file's chanblocks when a file had no RESULTS HDU, so for a picket-fence observation spanning several files it was silently shorter than the concatenated chanblock axis callers index it against. Added a multi-file regression test; the existing test only covered the single-file case, which is why this was missed.run_command_ext()bound NUMA node 0 butrun_command_popen()did not (>= 0vs> 0), even though 0 is a valid node and -1 is the "do not bind" convention. Both now share one_apply_numa_binding()helper. Also correctedrun_command_popen'snuma_nodeannotation, which saidintwhile the body handledNone.obs_idbeing assigned astrdespite itsintannotation, and removed an unreachable branch that referenced a non-existentargs.solution_path.--gain-max-cutoffhelp text claimed a negative value disables the check. It does not -- only--no-gain-max-cutoffdoes, and a negative ceiling would flag every channel of every tile..vdiffile not matching the expected name pattern raisedAttributeErrorinstead of being skipped, as the log message referenced a non-existentargs.obs_id.validate_filename(): the "filename has no extension" check testedlen(os.path.splitext(...)) == 2, which is always true, so such filenames were reported with a misleading "Unknown file extension" message instead.read_subfile_values(): a PSRDADA keyword appearing twice in a header counted twice towards the "found them all" early exit, so a later requested key could be missed.add_digital_gains_column()returned afits.BinTableHDUthat escaped itswith fits.open(...)block; astropy loads HDU data lazily, so using it after the file was closed was unsafe. It now returns a bool indicating whether the column was added.push_message_to_redis's own 3 retries), the handler calledsys.exit(3). That runs on a worker thread, wheresys.exit()only raisesSystemExitin that thread -- the thread died, the exit code was discarded, and the main loop then logged "Completed Successfully" whilemain()exited 0. A redis outage therefore silently stopped subfile distribution on that host and looked like a clean stop to systemd, so it would not be restarted and would not alert. There were three such thread-levelsys.exit()calls (exit codes 2, 2 and 3).request_fatal_shutdown(exit_code, reason)on the processor, which records the intended exit code, logs it, and asks the main loop to stop. The main loop reports the failure rather than claiming success, andmain()exits with that code. The first caller wins, so the exit code reflects the root cause rather than a knock-on failure. Redis being unreachable remains deliberately fatal for mwax_subfile_distributor: shutting down is what raises the alert.main(). Same mechanism applied. Both daemons also now exit 1 (not 0) wheninitialise()orstart()raises.ResourceWarning: unclosed filewarnings from test005 (ConfigParser.read()takes a path, so no handle needs opening) and test014 (Path.read_text()). The suite is now warning-free apart from onePendingDeprecationWarningraised inside seaborn.initial * factor * consecutive_failures(2, 4, 6, 8, ...) rather thaninitial * factor**(n-1)(1, 2, 4, 8, 16, ...), so a persistently failing item was retried far more aggressively than intended. BothQueueWorkerandPriorityQueueWorkernow share a singlecalculate_backoff_seconds()helper rather than each computing it inline, so they cannot drift apart again. Added regression tests.stop()sets athreading.Eventto interrupt an in-flight backoff wait, but nothing ever cleared it. After the firststop(), every subsequentevent.wait(backoff)returned immediately, silently disabling backoff for the rest of the process's life.start()now clears it. Added a regression test.MWAXDBHandler.execute_single_dml_row()carried its own@retrydecorator identical to the one onexecute_dml, which it does nothing but call. The two nested retries multiplied: up to 9 attempts instead of 3, and a worst case of roughly 5 minutes instead of 1. The redundant outer decorator has been removed so the retry policy is defined in one place.utils.call_webservice():max_retriesran one more attempt than its name and docstring implied (while attempt <= max_retriesstarting from 0). It now means exactly that many attempts at the whole URL list. Note this is a small behaviour change: the defaultmax_retries=3now makes 3 attempts rather than 4, andrelease_cal_obs(which passes 1) makes 1 rather than 2 -- that call already sits inside a 60-minute outer retry loop, so it is not weakened in practice. Failed attempts are now also logged individually.utils.run_giant_squid(): retry backoff is collapsed to ~0 under pytest. With the defaults the loop slept 10+20+40+80+160 = 310 real seconds before giving up, so a single unit test took over five minutes whenever the giant-squid binary was absent. The retry logic is still exercised; only the wall-clock wait is skipped.plot_upload_interval_secsis a sleep between passes, not a fixed-rate timer, so with the production value of 600s a backlog drained at only one batch (100 fit dirs) per 10 minutes while the thread sat idle in between.upload_published_fit_dirs()now returns whether the path still has fit dirs waiting, and a pass that left a backlog behind sleepsPLOT_UPLOAD_BACKLOG_DELAY_SECS(5s) instead of the full interval. The base directory is still re-scanned every pass, so a newly published fit is still uploaded ahead of the backlog. A total failure still falls back to the full interval and per-path backoff rather than hot-looping.stop()during an in-flight pass had to wait out every rclone transfer across all seven upload paths. It is now checked between paths and before each fit dir, and any dir not reached is simply left published for the next run.Code review changes:
src/andtests/withruff formatat the configured 120 character line length. Many files had been formatted at 88 characters previously, so line wrapping was inconsistent across the codebase. No code changes: verified by comparing the AST of every file before and after. Also added a[tool.ruff.format]section topyproject.tomlrecording the intended style, so this does not drift again.ruff formatpre-commit hook (.pre-commit-config.yaml) so formatting stays consistent automatically rather than relying on remembering to run it. Enable it in a clone withuv run pre-commit install. The hook version is pinned deliberately, sinceruff formatoutput can change between ruff releases.ruff checkis not part of the hook yet: the codebase still has ~90 outstanding lint findings, which would block commits to any file containing one.generate_hyperdrive_plots()have been merged into one (inmwax_calvin_plots.py). The removed copy acceptedmax_ampbut notbefore, and ignoredrun_command_ext's return code, so a failed hyperdrive run was reported as a success -- which meantupdate_calvin_plots_and_index's "exit early on failure" check could never fire. The surviving implementation takes both parameters and checks the return code.ChecksumAndDBProcessorandOutgoingProcessornow requeue a failed file to the back of the queue instead of retrying it in place. Order does not matter for either, and retrying in place meant one permanently-bad file blocked the head of the queue indefinitely, stalling everything behind it.shutil.move()rather thanos.rename()when routing files between the incoming/outgoing/dont_archive directories, so a configuration which places those paths on different filesystems does not raiseEXDEV. This matches what the rest of the codebase already does.recursiveconfig option was read but never used -- incoming paths were always watched non-recursively regardless of the setting. It is now passed through to the watcher.create_sbatch_script()'srequest_idsparameter was annotatedlist[str]whilesubmit_sbatch()'s waslist[int], and the same call site converted for one and not the other. Request IDs are an integer database column, so they are nowlist[int]throughout.insert_calibration_fits_row()no longer rolls back the transaction itself. The caller owns the transaction (it runs inside awith conn.transaction():block and raises on failure), so both were trying to unwind the same transaction.flag_amplitude_outliers()andrun_flagging_pipeline()'smad_residual_thresholddefault is now 10.0, matching the shipped config andcal_utils --mad-threshold. It was 5.0, so calling either directly without a config value behaved differently to the deployed pipeline. The corresponding caveat in CALVIN.md has been removed.np.round()wrapped around a floor division, which was a no-op.make_illustrations.pynow usesiterative_poly_clip_batch, the implementation the production pipeline actually calls, rather than the per-tileiterative_poly_clip. The two differ in their zero-MAD handling, so the illustration could have shown behaviour the pipeline does not have. The generated PNGs are unchanged.Documentation and comment corrections found during code review. No executable code changed (verified by comparing every file's AST with docstrings stripped and other strings blanked), other than renaming two functions in
docs/img/make_illustrations.pyHyperfitsSolutionGroup.combined_tile_flagssaidrefantdid not use it and was "left as-is for now" --refanthad since been switched over, so the two docstrings directly contradicted each other.fit_phase_linestill documented its sigma-clip as rejecting "outliers beyond 2*resid_std" in three places (theniterarg, the quality-metric comment, and the_MIN_CLIP_THRESHOLD_RADcomment). It has used a robust median + MAD clip since the phase-fit speedup work.mwax_calvin_solutions' module docstring listed the flagging pipeline in the wrong order and omitted the gain-magnitude cutoff stage.flag_mostly_bad_tilesomittedGAIN_MAX_CUTOFFfrom its list of per-channel reasons.populate_index_json_entrysaid only.png/.tsv/.txtwere supported; it also accepts the twosolutions.fitsflavours.HyperfitsSolutionGroup.__init__documentedmetafitsas a list, andget_metafits_chan_inforeferred to "all metafits files". There is one.inject_beamformer_headerssaid it appends to the subfile header; it overwrites the last line.remove_fileclaimed up to 5 retries; the decorator allows 3.run_hyperdrivedescribed its input as one uvfits file per coarse channel; it is one per contiguous coarse-channel band.copy_file_rsync's docstring had source and destination the wrong way round -- its only caller pulls a remote file to a local directory.utils.py's module docstring and the README both documented anMWAXSubfileDistirbutorModeenum which does not exist anywhere in the codebase.estimate_birli_output_bytes' comment gave the baseline count astiles * tiles + 1.iterative_poly_clip_batchclaimed to be "mathematically identical" to the per-tile version. It now documents the one real difference (zero-MAD tolerance) and whymake_illustrations.pyuses the batch version.get_unattempted_calibration_requestsused aParameters:block documenting ahostnameargument it does not take._checksum_and_insert_db's tri-state return is now spelled out (Nonemeans success), and theRaises:section no longer documents an exception it catches rather than raises.VisStatsProcessor.handlernow states that a failedmwax_statsrun is deliberately tolerated.Module Reference: corrected entries forversion.py,mwax_db.py,mwa_archiver.py(documented a function deleted earlier in this branch and omitted the one actually in use),mwax_wqw_pawsey_outgoing.py,mwax_calvin_utils.pyandmwax_calvin_solutions.py; added missing entries formwax_hyperdrive_solutions.pyandmwax_calvin_plots.py.cal_utils,update_calvin_plots_and_index,generate_index_json,fits_inspect,print_metafits_info,vdif_cat) were entirely undocumented. All 10 console scripts are now listed.step3a/step3btostep6a/step6b. Phase-outlier detection became Step 6 when the pipeline was reordered, but the filenames (and a cross-reference inmwax_calvin_plots.py) still said Step 3.mwax_wqw_*processor classes andArchiveLocation.mwax_priority_queue_data's module docstring example calledq.put(1, 'path/file'), which is not howPriorityQueue.put()works.# FIX 1:/# FIX 2:/# FIX 3:changelog-style annotations inrun_hyperdrivewith comments stating the invariant each one maintains.Formatting
E501finding (ruff checkis now down from 86 findings to 26). These are the linesruff formatcannot fix for you, since it will not split a string literal. Purely mechanical: verified by comparing every file's AST with all string constants' whitespace collapsed, so no word, punctuation mark or format placeholder was altered and no code changed.ruff checkis still not a CI/pre-commit gate: the remaining 26 findings areB905(17,zip()withoutstrict=),B017,B904,B007andB018, which need real decisions rather than reflowing.Tooling
.github/workflows/ci.yml), running on pushes tomain, on every pull request, and on demand. Two jobs: a fastlintjob (ruff format --check,ty check) and atestjob running the suite against Python 3.12 and 3.13. Until now nothing was enforced on push -- the pre-commit hook only protects developers who have runpre-commit install. Notes:ruff checkruns but is deliberately not a gate yet: there are ~86 outstanding findings (mostlyE501on long string literals the formatter cannot split), so blocking on it would make CI red from the first run. Its findings appear in the job log and as a counts table in the run summary. The workflow comment records exactly what to change onceruff check .is clean.integrationmarked tests are not run: they need network access and the real giant-squid binary. They remain deselected by theaddoptsinpyproject.toml.test020_calvin_solutions.py.