Repository navigation
Fix failed jobs polling forever: check for 'error' status, not 'failed' - #159
matthewberry wants to merge 9 commits into
Conversation
The backend reports failed jobs with phase 'error', but the results page checked for 'failed', so failed jobs polled forever and never showed the failure message.
945b2fd to
cece705
Compare
- Check for a terminal job status before parsing job_info, so a malformed or missing job_info on an errored job can't keep polling alive. - Wait 10s before retrying a failed status request instead of retrying immediately in a tight loop. - Type PollingResponseResult.status as JobStatus and replace the remaining hand-typed status strings. - Drop the unneeded 'as Job' cast in the results spec.
- ResultService: cap status retries at 5 consecutive failures so errors reach the subscriber instead of retrying forever. - ResultsComponent: show the failure message when status polling errors, stop polling and show it when the job id matches no job, and skip the sequence count when job_info is missing instead of throwing every poll. - ResultsComponent: read the job id from ActivatedRoute.paramMap instead of window.location, reloading on each id change since the router reuses the component between /results/:id URLs. Specs no longer depend on the Karma page URL.
|
Review follow-up, pushed as 0554a39. Each fix below has a spec that failed before the change and passes after it. Full suite: 17/17 passing. Reproduced and fixed
Checked, not a bug
Not addressed
Open questions
|
Job.phase is optional in the generated client and the `!` hid that. The backend also has a 'canceled' status the generated enum lacks, so a canceled job polled forever. Treat every phase other than queued and processing as final, and show the failure message unless it completed.
|
Follow-up on The backend (mmli-backend
Fix: any phase other than New specs cover a job with no phase and a Two related notes:
From the same backend read: for an unknown id, the list endpoint returns |
…load getResponseResult() only logged an error from the results request, so contentLoaded stayed false and the loader spun forever. That happens for a completed CLEAN job with no _maxsep.csv: the backend's results endpoint answers 404. Jobs like that already exist (crashed runs that were marked completed before moleculemaker/mmli-backend#161). Set failedJob and contentLoaded in that error handler, as the status-polling error handler already does.
|
Review follow-up, pushed as d111b3e. Reproduced and fixed
Answers the open question from the first follow-up
Still not addressed (unchanged)
Merge order: this should be deployed before mmli-backend#161. With that change, crashed CLEAN runs are reported as |
| // this.tempSelectResult() | ||
| ), | ||
| retry(), | ||
| retry({ count: 5, delay: 10000, resetOnSuccess: true }), |
There was a problem hiding this comment.
A backend outage of about a minute now permanently fails a job that is still running. Six status calls failing in a row (the first try plus 5 retries, 10s apart) sends the error to ResultsComponent, which sets failedJob and shows "Job failed…". Nothing polls again, so the user never sees the results unless they reload. Before this change, retry() kept polling until the backend came back.
This test reproduces it (it fails with Expected spy onError not to have been called.):
it('keeps polling through a one-minute backend outage', fakeAsync(() => {
const processing = [{ job_id: 'abc', phase: JobStatus.Processing }];
let calls = 0;
spyOn(TestBed.inject(JobsService), 'listJobsByTypeAndJobIdJobTypeJobsJobIdGet')
.and.callFake((() => ++calls <= 6 ? throwError(() => new Error('503')) : of(processing)) as any);
const onNext = jasmine.createSpy('onNext');
const onError = jasmine.createSpy('onError');
const subscription = service.getResult('abc').subscribe({ next: onNext, error: onError });
tick(2 * 60 * 1000);
expect(onError).not.toHaveBeenCalled();
expect(onNext).toHaveBeenCalledWith(processing);
subscription.unsubscribe();
discardPeriodicTasks();
}));If the goal is to stop polling a job that will never be found (e.g. a 404), could the retry be limited to that case, or allowed to run much longer than a minute? surfaces the error to the subscriber when status requests keep failing still passes as long as there's some limit under 10 minutes.
There was a problem hiding this comment.
Agreed, fixed in a448e6c.
The cap only bought us something for errors that won't clear up, and the status endpoint has almost none. On mmli-backend origin/main, list_jobs_by_type_and_job_id (app/routers/job.py) has no auth dependency, and nothing in main.py can reject the request. Its only 4xx is a 400 for an invalid job type, and this page hardcodes 'clean'. An unknown id is a 200 with [], which the component already treats as not found. What's left is network errors and 5xx during outages, which clear up on their own.
So retry now retries network errors and 5xx every 10s for as long as they last, which is the behavior before this PR. A 4xx (HttpErrorResponse with status 400–499) ends polling right away, and the page shows the failure message.
Specs in result.service.spec.ts:
- Your outage case, made longer: 60 failed calls (about ten minutes) followed by a
processingresponse, run once with 503s and once with network errors (status 0). Neither errors, and polling resumes. - A 404 surfaces right away with no retry.
- The existing "waits before retrying" spec now uses an
HttpErrorResponse500.
Without the change, those three new specs fail. With it, 22 of 22 pass, and ng build succeeds.
| if (this.statusResponse.status == 'completed' || this.statusResponse.status == 'failed') { | ||
| // Any other phase is final, including a missing one or one the generated enum | ||
| // lacks (the backend also has 'canceled'). | ||
| if (firstMatch.phase != JobStatus.Queued && firstMatch.phase != JobStatus.Processing) { |
There was a problem hiding this comment.
This treats a missing phase as final and failed. Job.phase is optional in the generated model, so if jobmgr can ever return a row before its phase is written, the job is marked failed and polling stops for good, even though the job then runs normally.
This test reproduces it (polling has stopped, failedJob is true, results are never fetched, and .failJob is showing):
it('keeps polling when a poll returns the job without a phase', () => {
polls.next([{ ...job(JobStatus.Processing), phase: undefined }]);
fixture.detectChanges();
expect(polls.observed).toBeTrue();
expect(fixture.componentInstance.failedJob).toBeFalse();
polls.next([job(JobStatus.Completed)]);
fixture.detectChanges();
expect(fixture.componentInstance.failedJob).toBeFalse();
expect(resultService.getResponse).toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.failJob')).toBeNull();
});It directly contradicts stops polling and shows the failure message when the job has no phase, so the question is whether the backend can actually return a job without a phase. If it can't, ignore this. If it can, I'd keep polling when phase is missing and treat only known final phases (completed, error, canceled) as final.
There was a problem hiding this comment.
Checked, and the backend can't return a job without a phase, so I've left this as is.
- The column is never null:
phase: JobStatus = Field(default=JobStatus.QUEUED, nullable=False, …)inapp/models/sqlmodel/models.py(mmli-backendorigin/main). - The field is never dropped:
list_jobs_by_type_and_job_idreturns the ORM rows directly, with noresponse_modelorexclude_nonethat could leavephaseout. The only optionalphasein that file (line 178) is on a separate model, not the one this route returns. - No new phase is coming: open mmli-backend#153 (the queued-until-the-container-starts change) sets the existing
queued. It doesn't add a phase value, so nothing new is on the way that this check would wrongly treat as final.
The missing-phase spec is a guard against a response that can't happen today. If the backend ever adds a phase that isn't final, this check would show "Job failed" for a job that's still running. The JobStatus enum is where that phase would be added, and the generated client would need regenerating then anyway.
There was a problem hiding this comment.
Confirmed on mmli-backend: phase is nullable=False with a default of queued (app/models/sqlmodel/models.py:100). The missing-phase case can't happen, so the existing spec is right and mine was wrong. Resolving from my side.
retry({ count: 5 }) turned about a minute of failed status calls into a
permanent "Job failed" for a job that was still running: the error reached
ResultsComponent, which stopped polling for good.
Retry network errors and 5xx every 10s for as long as they last, as the
page did before this PR. A 4xx won't fix itself, so it still ends polling
and shows the failure message. An unknown job id doesn't need the cap: the
backend answers it with 200 and [], which the component already handles.
| // Keep polling through an outage (network errors and 5xx), however long it lasts: | ||
| // the job may still be running. A 4xx won't fix itself, so it ends polling and the | ||
| // page shows the failure message. An unknown job id is a 200 with [], not a 404. | ||
| retry({ |
There was a problem hiding this comment.
Only an HttpErrorResponse with a 4xx status ends polling, so any other error is retried forever. One of those is reachable: on the bare /results route (app-routing.module.ts:11) there's no :id, and the generated client throws a plain Error('Required parameter jobId was null or undefined…') before any request is sent. At d111b3e the 5-retry cap showed the failure message after about a minute. Now the loader spins forever.
This test fails at a448e6c with Expected spy onError to have been called. (it uses the real JobsService, no spy):
it('surfaces the error when there is no job id (the bare /results route)', fakeAsync(() => {
const onError = jasmine.createSpy('onError');
const subscription = service.getResult(undefined).subscribe({ error: onError });
tick(10 * 60 * 1000);
expect(onError).toHaveBeenCalled();
subscription.unsubscribe();
discardPeriodicTasks();
}));Low severity: you only get there by typing the URL, and main also polled forever. Either show the failure state before polling when there's no id, or retry only HttpErrorResponses with status 0 or 5xx and treat everything else as final.
There was a problem hiding this comment.
Confirmed and fixed. I took your second option: only failures that can clear up are retried, and everything else is final.
- b751745: retry only an
HttpErrorResponsewith status 0 or 5xx. Anything else ends polling and shows the failure message, including the plainErrorthe generated client throws for a null or undefinedjobId(jobs.service.ts:337–338). Your spec is inresult.service.spec.ts, with the wait shortened from 10 minutes totick(1), since the error now arrives right away. It fails at a448e6c and passes now. - d866192: 408 and 429 are also retried. A proxy or ingress in front of the backend can send them while the job is still running. This came up in review on iBioFoundry/crispr-copies-frontend#19, which shares this logic. The outage spec now covers 503, 0, 408 and 429, and the 408 and 429 cases fail at b751745.
25 of 25 pass, and ng build succeeds.
a448e6c ended polling only on an HttpErrorResponse with a 4xx, so any other error was retried forever. One is reachable: on the bare /results route there's no job id, and the generated client throws a plain Error before sending a request, so the loader spun forever. Retry only an HttpErrorResponse with status 0 (network) or 5xx. Anything else, including that Error, ends polling and shows the failure message.
A proxy or ingress in front of the backend can answer 408 or 429 while the job is still running. b751745 ended polling on them. Retry them like a network error or 5xx. (From review on iBioFoundry/crispr-copies-frontend#19, which shares this logic.)
Closes #158.
Problem
The backend reports a failed job with phase
'error'(seeJobStatusin the generated client), but the results page checked for'failed'. When a job failed, the page kept pollingGET /clean/jobs/{id}every 10 seconds and never showed the failure message.Change
results.component.ts, the two status checks (stop polling, setfailedJob) now compare againstJobStatus.Errorinstead of'failed'. The'completed'check now usesJobStatus.Completed. Completed jobs are handled the same as before.PollingResponseStatus.statusis now typedJobStatusinstead ofstring. The results component is the only code that uses this type. With this type, a comparison against'failed'doesn't compile (TS2367).Testing
Added tests in
results.component.spec.ts. They use a stubbedResultServicewhose polling stream ends whengotEndResult()is called, like the real service:errorjob: polling stops,failedJobis set, results aren't fetched, and.failJobrenderscompletedjob: polling stops, results are fetched, and there's no failure messageprocessingjob: polling continuesnpx ng test --watch=false --browsers=ChromeHeadless: 9 of 9 pass on this branch, which is rebased ontomainafter #160. With the old'failed'checks restored, the error-job test fails.ng buildsucceeds. I didn't test against a real failed job in a browser.