Refuse to update a spectral library when none was given - #2721
Conversation
…isc#2291) Checking "update spectral library" without supplying one ran the entire search and then dereferenced a null library in UpdateSpectralLibrary. The reporter saw the task appear to hang on "Writing PSM results", with a NullReferenceException only visible in results.txt after force-quitting. The dereference is still there on master, but the symptom has changed rather than gone: UpdateSpectralLibrary is now wrapped in try/catch, so the exception is written to a crash file and the task returns normally. A test asserting a throw against current code gets no exception at all - the run looks successful and the error is buried in the output folder. SearchTask now refuses the combination immediately after loading spectral libraries, where the null is already known: LoadSpectralLibraries returns null when the database list has no library, and the line just below the search loop is already written to handle that. Reporting it there costs a database load rather than a whole search. UpdateSpectralLibrary keeps a null guard that warns and returns, for a caller that builds PostSearchAnalysisParameters itself. It does not throw, because by then the search has finished and its results are already written. Test/SpectralLibraryUpdateTests.cs asserts the refusal, and that the message names what is missing. It passes in about half a second, which is the point - reaching the check no longer requires searching. Verified failing against unfixed code, where no exception is raised at all. Run locally through a net10.0 TaskLayer harness, since the Test project targets net10.0-windows and cannot run on macOS. The redundant positive control I first wrote - update with a library present, assert the guard stays quiet - was dropped: TestLibraryUpdate in MatchIonsOfAllCharges already exercises that path end to end, and duplicating it cost a full search. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Please upload reports for the commit a46a840 to get more accurate results.
Additional details and impacted files@@ Coverage Diff @@
## master #2721 +/- ##
==========================================
+ Coverage 93.32% 93.36% +0.03%
==========================================
Files 214 214
Lines 21801 21808 +7
Branches 4080 4082 +2
==========================================
+ Hits 20346 20361 +15
+ Misses 901 888 -13
- Partials 554 559 +5
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
trishorts
left a comment
There was a problem hiding this comment.
Nice diagnosis, and I agree with both the framing and the decision not to silently fall back to generating a library. Three things below, plus a note on coverage.
I verified each of these against the branch rather than reasoning from the diff — file:line references are to this PR's head.
Patch coverage. Codecov is red at 57.14% (7 patch lines, 4 hit / 2 miss / 1 partial). All three uncovered lines are the new guard in PostSearchAnalysisTask.cs:823-826 — unreachable through SearchTask by construction, which is exactly the point of it. I've written a set of fast direct-construction tests that cover it (and the update logic generally, which today is only exercised by a 7-second full search) and will open them as a PR against this branch so you can take or leave them.
|
|
||
| // Checked here rather than where the library is updated, which happens after the whole search: | ||
| // there is nothing to update, and reporting that once the search has finished wastes the run. | ||
| if (SearchParameters.UpdateSpectralLibrary && spectralLibrary == null) |
There was a problem hiding this comment.
The check lands nine lines too late to deliver what the PR describes.
var proteinLoadingTask = dbLoader.RunAsync(); already fired at line 171, and RunAsync is Task.Run(Run) (EngineLayer/MetaMorpheusEngine.cs:318) with no cancellation token — there is no CancellationToken overload anywhere in TaskLayer/EngineLayer, and DatabaseLoadingEngine.RunSpecific never checks GlobalVariables.StopLoops. Throwing here abandons that task: it runs the FASTA load, decoy generation and ScrambleHomologousDecoys to completion on a thread-pool thread after RunTask has unhooked FinishedSingleEngineHandler (MetaMorpheusTask.cs:685) and rethrown, and any exception it raises is never observed.
The check doesn't need any of that work. LoadSpectralLibraries is a pure filter over dbFilenameList — dbFilenameList.Where(p => p.IsSpectralLibrary), returning null when the list is empty (MetaMorpheusTask.cs:741-746), no I/O. So the whole test can move above line 170 and the cost of the mistake becomes nothing at all, rather than "a database load".
In practice the leak is small (thread-pool work, and WriteTargetDecoyFasta defaults to false so nothing is written), and SearchTask already abandons this task on other throw paths — :301 for an unknown isobaric mass tag. So this isn't a new class of problem. But it is the one line of the PR whose placement is the whole argument, and hoisting it is free.
| // there is nothing to update, and reporting that once the search has finished wastes the run. | ||
| if (SearchParameters.UpdateSpectralLibrary && spectralLibrary == null) | ||
| { | ||
| throw new MetaMorpheusException( |
There was a problem hiding this comment.
Worth knowing before you settle on throw: in the GUI this surfaces as a crash report prompt, not as a configuration message.
I traced it end to end. RunTask catches, writes a full dump into results.txt — system info, stack trace, TargetSite — and rethrows (MetaMorpheusTask.cs:683-700). EverythingRunnerEngine.Run has no try/catch around RunTask. MainWindow wires the runner with t.ContinueWith(EverythingRunnerExceptionHandler, TaskContinuationOptions.OnlyOnFaulted) (GUI/MainWindow.xaml.cs:1020), and that handler (:431-490) does not special-case MetaMorpheusException:
MessageBox.Show(message + "\n\nWould you like to report this crash?", "Runtime Error", MessageBoxButton.YesNo);
Answering Yes composes a mailto to mm_support@chem.wisc.edu with the stack trace and the toml. So a user who ticked one checkbox too many is told MetaMorpheus crashed and invited to file a bug about it. (Since you exercised this through a net10 harness on macOS, you'd have had no way to see that.)
The house channel for refusing a bad database/task combination is EverythingRunnerEngine.Run, which already does three of these with Warn(...) + FinishedAllTasks(...) + return — including one that inspects IsSpectralLibrary (EverythingRunnerEngine.cs:78-86, "Cannot proceed. No protein database files selected."). That gate runs before RunTask, has access to both the task list and the database list, and already has a test asserting on its warning string (Test/MyTaskTest.cs:839 MissingDbInSpectralLibrarySearch). Putting the refusal there would be earlier still, and would reach the user as a notification rather than a crash dialog.
Counter-argument, which is real: CalibrationTask.GenerateIndexes (CalibrationTask.cs:349-361) throws MetaMorpheusException for exactly this shape of impossible combination and is the newest precedent in the repo, and SearchTask.cs:301 does the same. So the inline throw isn't out of keeping. I'd still rather this one didn't read as a crash — either move it to the runner, or keep it here and teach EverythingRunnerExceptionHandler to present MetaMorpheusException as a message rather than a crash report (that second one is its own PR).
Either way, not something to fix silently — your call, and worth saying out loud in the PR description if you keep the throw.
| string spectra = Path.Combine(TestContext.CurrentContext.TestDirectory, | ||
| "TestData", "TaGe_SA_A549_3_snip.mzML"); | ||
|
|
||
| var thrown = Assert.Throws<MetaMorpheusException>(() => task.RunTask( |
There was a problem hiding this comment.
The test name and doc comment promise "before searching"; the assertions only prove "at all".
The three assertions are Assert.Throws<MetaMorpheusException> plus two substring checks on the message. Nothing observes that the search loop was skipped. The check could migrate anywhere upstream of PostSearchAnalysisTask — after the per-file search loop, say — and this test stays green, which is the half of the fix you describe as "as much the substance of the fix as the assertion is".
(To be precise about the one place it would catch: moving the throw into UpdateSpectralLibrary() itself would be swallowed by the try/catch at PostSearchAnalysisTask.cs:820/892-895 and Assert.Throws would fail on "But was: null" — the same failure you quote in the description. So the test does pin some earliness. It just doesn't pin "before the search".)
Cheapest fix that pins it, after the expected exception:
Assert.That(Directory.GetFiles(outputFolder, "*.psmtsv", SearchOption.AllDirectories), Is.Empty,
"the refusal has to come before the search, not after it");
Assert.That(Directory.Exists(Path.Combine(outputFolder, "Individual File Results")), Is.False);Two smaller things while you're in here:
- The message assertions cover the diagnosis but not the remedy.
Does.Contain("no spectral library was given")subsumesDoes.Contain("spectral library"), and neither pins the half of the message that tells the user what to do about it — the string could be trimmed to the diagnosis and this stays green. One more substring on the "add one to the list of databases / write a new one instead" clause would cover it. RunTaskwrites its toml toDirectory.GetParent(output_folder)/"Task Settings"(MetaMorpheusTask.cs:627), i.e.SpectralLibraryUpdateTests/Task Settings/, one level above the folder you delete — so it survives every run. And the delete is the last statement rather than afinally, so a failing assertion leavesNoLibrary/behind too.CalibrationTests.cs:273andBinGenerationTest.cs:80are the sibling pattern. (The test PR I'm sending you handles this with a[OneTimeTearDown]on the wholeSpectralLibraryUpdateTestsroot.)
PR smith-chem-wisc#2721 is at 57% patch coverage. Every uncovered line is the null guard in PostSearchAnalysisTask.UpdateSpectralLibrary, which SearchTask now makes unreachable by refusing the combination before searching -- so the only way to reach it is the case the guard was written for: a caller assembling PostSearchAnalysisParameters itself. These tests do exactly that. Parameters has a public setter and SearchTask.cs:531 already constructs the task that way in production, so no search is needed; the whole fixture runs in under a second where the one existing update test (MatchIonsOfAllCharges.TestLibraryUpdate) takes seven and needs two mzML files. Beyond the guard, they pin the update logic itself, which until now was observable only through that full search: originals survive when there are no PSMs, the better of the stored spectrum and the search result wins, an unseen peptide is appended, and decoys and low-confidence matches stay out of a library the next search will trust. Removing the guard fails UpdateSpectralLibraryWithoutALibraryWarnsAnd- LeavesNothingBehind, so the coverage is real rather than incidental. Also adds a OneTimeTearDown for the "Task Settings" folder RunTask writes beside the output folder, which the existing test leaves behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DatabaseLoadingEngine.RunAsync is Task.Run with no cancellation token, so throwing after it left the FASTA load, decoy generation and ScrambleHomologousDecoys running on a thread-pool thread after RunTask had unhooked its handlers and rethrown. The check does not need any of that. LoadSpectralLibraries is a filter over dbFilenameList that returns null when nothing in it is a library, so the precondition is available before the load starts. Extracted as AnySpectralLibrary so the guard and the loader cannot drift apart, and hoisted above RunAsync. Reaching the refusal now costs no I/O. The test asserts this by watching StartingSingleEngineHander rather than by looking for output files: every result file is written by PostSearchAnalysisTask, so a check placed anywhere ahead of that leaves the output folder empty whether or not the search ran. With the guard moved back down the assertion fails naming DatabaseLoadingEngine and ClassicSearchEngine. Also pins the remedy half of the message, which nothing covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both spectral library writers concatenated a literal backslash onto the output folder. On Windows that is the separator, so nothing changes there. On macOS and Linux a backslash is an ordinary filename character, so the library was written into the *parent* directory under a single name containing a backslash, and the path handed to NewDatabases pointed at something the next task could not resolve. Found because five of the eight tests in the previous commit fail on macOS and pass on Windows; with this they pass on both. CI runs the test project on windows-latest only, so it could not have surfaced there. Pre-existing and separate from what this PR is about -- drop this commit if you would rather keep the PR to the guard. The same pattern is in ten more places in PostGlycoSearchAnalysisTask, deliberately left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🤖 Fixes #2291.
Asking MetaMorpheus to update a spectral library without giving it one has always ended badly, because
UpdateSpectralLibrarydereferences the library without checking whether it exists. What makes this worth more than a one-line null check is where in the run it happens: the search completes in full, every result is written, and only then does the code reach for a library that was never there.Thanks @nbollis for the report, and particularly for the detail in it. The stack trace, the version, and the observation that the task stalled specifically during "Writing PSM results" between them identified the failing line and the point in the run it was reached from, which is most of the diagnosis.
The behaviour described in the report no longer reproduces
When #2291 was filed against version 1.0.2, the failure announced itself: the task appeared to hang, and the
NullReferenceExceptionsurfaced in results.txt only after force-quitting. Somewhere since thenUpdateSpectralLibraryacquired atry/catch, which means it no longer hangs, and also that it no longer visibly fails. The exception goes toEngineCrashed, which writes a crash file and lets the task return as though all were well, so a test asserting a throw against current master finds nothing to catch:The live defect is therefore a run that reports success while quietly producing no updated library, with the reason sitting in an
UpdateSpectralLibrary_crash.txtthat nobody has reason to open. In one respect that is worse than the original hang, which at least demanded attention.Refusing early, where the answer is already known
The information needed to prevent this has been available all along.
LoadSpectralLibrariesreturns null when the database list contains no library, and the code immediately below the search loop already accounts for that possibility:Nothing, however, consulted it before committing to a search.
SearchTasknow checks the combination as soon as the libraries are loaded and refuses it there, so the cost of the mistake is a database load rather than an entire search, and the message explains the two ways out: supply a library to update, or ask for a new one to be written instead.UpdateSpectralLibraryretains a null guard that warns and returns, which matters for any caller assemblingPostSearchAnalysisParameterson its own. That guard deliberately does not throw. By the time it runs the search has finished and its results are on disk, and discarding all of that over a request that cannot be honoured would be a poor trade.Test
A single test in
Test/SpectralLibraryUpdateTests.csasserts the refusal and checks that the message names what is missing rather than merely reporting that something went wrong. It finishes in roughly half a second, which is as much the substance of the fix as the assertion is: reaching the check no longer requires searching anything. It fails against unfixed code, where, as above, nothing is thrown at all.Testtargetsnet10.0-windowsand cannot run on macOS, so I exercised it through a throwaway net10.0 harness overTaskLayer. CI remains the real check.One thing deliberately left alone
A tempting alternative is to fall back to generating a library from the search results, which
SpectralLibraryGenerationdoes a few lines above and which is plausibly what someone ticking this box actually wants. I have not done that, because it turns a bug fix into a behaviour change: it would emit a library through a path the user did not select, quietly reinterpreting one request as another. Refusing clearly and letting the user choose seemed the more honest option, though I am happy to take the other road if you would prefer it.