Fix infinite route-loader spinner from per-navigation root cache inva… - #5936
Fix infinite route-loader spinner from per-navigation root cache inva…#5936naomigassler wants to merge 1 commit into
Conversation
…lidation BrowserInitService invalidated the root /server/api endpoint cache on every NavigationStart. That marks the root request stale (RootDataService .invalidateRootCache -> RequestService.setStaleByHref). HALEndpointService .getEndpointMapAt, used by getEndpoint() for every data request, discards a stale root via filter(rd => !rd.isStale), and its re-fetch can lose the race with the next invalidation, so getEndpoint() never resolves, the route resolver never completes, and the route loader spins forever. The root endpoint map is static between navigations, so invalidating it on every navigation is unnecessary. Remove the per-NavigationStart invalidation and keep the one-time invalidation at app init; backend availability is still established there and surfaces through normal request failures. Fixes DSpace#3584, DSpace#3697.
|
I was able to reproduce the issue with the DSpace sample data. The steps to reproduce it locally were:
I tested this PR, and the issue did not occur anymore 🎉. But then again, I tried to reproduce the original issue on main, just to double check - and could not successfully reproduce it anymore. So I would suggest:
Thanks for the PR! This is a great contribution in my opinion. |
|
Hey @tinsch and others in here. Starting point: I see the infinite route loader appear when I double-click very quickly on a navigation item.
Screencast on the DSpace sandbox page The issue can apparently be reproduced this way—unless I’m missing something. If I now apply the fix from the merge request, the error no longer occurs. Therefore, in my opinion, the fix makes perfect sense—especially since the logic in the underlying code apparently isn’t needed at all. So far, so good. But now I’m wondering if this really solves the problem. Even if we probably don’t actually need to manually invalidate the So, another approach or at least a try could be to swap the order of the following two lines: dspace-angular/src/app/core/data/base/base-data.service.ts Lines 312 to 314 in a52ba24 We could move the
No matter what sequence of clicks I used after making this change, I could no longer trigger the state where the loading spinner is displayed continuously. Long story short:
I’d be very interested in hearing your thoughts on these points, and of course whether you can reproduce the behavior. |
|
After further investigation, I might have found a better, more logic solution, which leaves my previous idea seeming obsolete. Original code: dspace-angular/src/app/core/data/request.service.ts Lines 155 to 176 in a52ba24 My (partial) code changes: This change targets to check Another, additional check I added in the Original code: dspace-angular/src/app/core/data/request.service.ts Lines 431 to 436 in a52ba24 My (partial) code changes: This change ensures that a request entry that exists in the request cache but is stale or expired is always re-fetched from the server—regardless of whether the associated object is still present in the object cache. Previously, in this case, the system would incorrectly fall back on the object cache and suppress the request, even though the data was marked as stale. As before, the fix in this merge request makes perfect sense. My additional changes could make the app even more robust. I have to do some more testing about these changes (especially in our customized instance)... but wanted to let you know about my current investigation. |
|
Thanks @jlipka for your investigation. I can reproduce the spinner issue on sandbox with your click sequence, but not locally. I tried my click sequence again to reproduce the issue, and it happened reliably on main and was fixed on the PR branch. However, I looked into the PR code and tested it further: The problem is, we now don't invalidate the backend route anymore on NavigationStart. This means that if the backend is down (simulated locally by turning off the backend container) the frontend won't notice. I can still click around in the UI and get some errors on certain pages, but this will be confusing for end users who don't know what's happening when the UI still looks somewhat fine. It can lead to data loss on the users side, and a lot of questions to the repository owners should the backend be down. Unfortunately I think we cannot use the fix for this reason. @naomigassler could you look into this please? Maybe there is a way to prevent the problem I described and still fix the spinner issues by tweaking the code a bit. To help with further investigations: The route was |
|
We measured every candidate on a test box against two scenarios: A the
Measured on our DSpace 9.3 deployment with added REST latency, not on A correction to our own issue report. The alternative we suggested there — On the objection. "Won't notice the backend is down" isn't quite what we So the concern was sound even where the mechanism wasn't, and chasing it turned up But on unmodified What the actual decision is. Today's
They are compatible. Happy to open the probe separately if there is appetite. On the Caveat. Our reproduction amplifies the production race with added REST latency, |
|
@naomigassler thanks for taking a look into it again. However I don't feel this conversation leads to a real fix, and it feels like a conversation with an LLM rather than a conversation between humans. I am sorry if I am wrong here. Could you please disclose it here and on your other PR? (#6111). If the comments above were not made by an LLM, please ignore the text below, and let me know I was wrong. If the comments and the code are entirely submitted by an LLM, I unfortunately have to step back from reviewing it further. The reasons are:
|
|
Hey, Regarding the actual issue and @tinsch’s valid comments: It seems a bit odd to use the user’s navigation/click behavior as the trigger for determining the status of the backend server. After all, in the best-case scenario, the user wouldn’t even be able to perform UI interactions if the server is actually unreachable—in the sense of a proactive check rather than a reactive response to the interaction itself. But that’s just a quick thought. |
|
@jlipka you are right, there might be a better way to to the liveness/health check than on every user navigation. It is just the way how it is implemented now. We could open a separate ticket for this. |
|
Hello @tinsch, i apologise for not having disclosed that my comments were written by an LLM. I am not a java developer but I have been working with programmers for more than 20 years on customising our dspace installation. Now i can do this myself with an LLM. Having spent many days on finding the solution for this bug, I was delighted to give something in return to the dspace project. but without prior experience in filing PRs in GitHub I relied on an LLM for help. I understand if that disqualifies this patch. I agree with @jlipka that we should have a different way to check if the backend is still alive. But I don't think I can contribute to this issue, I would be a bit out of my depth. |
|
@naomigassler thanks for disclosing it now, and thanks for taking the time to finding a fix with the help of an LLM. Using an LLM does not disqualify the patch, the problem is that the patch introduces other issues. I understand that you were happy to contribute something, and I myself also would have loved to find a fix for this annoying endless spinner issue! So thanks for bringing this up and attempting a fix. Do you want to have this PR and the other one (#6111) stay open, or can we close them and continue the work on a fix somewhere else? |
|
@tinsch I think this PR is the correct fix, we just need to find another way to check that the backend is still responding. #6111 fixes a real bug which is not apparent now but might be in the future. the fix is tiny and obvious so no harm to keep it. But at the moment it doesn't do anything so you might want to just close it. |
|
@naomigassler but how do you think we could proceed here? Since the fix needs adjustments, but you cannot provide them? |
|
This comment is a bit long, but it wraps up the entire story. So please bear with me. We have a reliable way to reproduce the hang on our system. So I looked precisely again (with the help of a coding agent) what is causing the hang. (I should have done this earlier but got too excited with finding a fix for a bug that has plagued my team for so long.) The timeline below involves two root request cache entries (…b577eb5662f8 and …259dce4bdb8f), reproduced with 2,500 ms injected REST latency and two navigations 800 ms apart. +340 ms +361 ms +1147 ms +1209 ms +1210 / +2918 ms then nothing, for ~27 s So there are two defects playing together: (a) isValid() calls a stale-but-loading entry valid — it tests isLoading before isStale, and isLoading includes ResponsePendingStale, so the stale check is never reached. This is precisely the predicate jlipka's reorder fixes. (b) skipWhile sits in front of the re-request. findByHref places skipWhile(rd => rd.isStale) immediately before reRequestStaleRemoteData — the operator whose entire job is "if this is stale, re-request it". In 3/3 runs its tap saw only RequestPending and ResponsePending, willReRequest: false; it never saw the stale completion. The culprit is this skipWhile, not anything in HALEndpointService. (The original description of this PR points at HALEndpointService as the culprit. This is now determined to be wrong.) #5936 removes one trigger of these 2 defects playing out, but it doesn't remove the defects. @jlipka was closer to the root cause than we were. Their first idea — moving I tried moving There is some extra cost: with the move, root requests per navigation go from 9–10 to 26–27 . That is the re-request actually happening — requests that should have been sent and were not — but it is roughly a threefold increase, and it may be worth looking at whether the re-request should be de-duplicated. For the path forward: this PR should now be closed, up to @jlipka to put in a PR with their reordering idea. Note: It is a question whether invalidating the root cache on every navigation is the best way to test whether the backend is responsive. I have written a probe for our system which proactively tests the backend at a given interval and gives the user a message when the backend becomes unresponsive even if the user is not navigating the UI. It is 283 lines across 13 files. Removing the invalidation of the root cache then becomes an efficiency gain. @tinsch you said earlier that you worry, understandably, about new bugs being introduced with new code. But, if there is an interest to try out this probe I'll be happy to put it into a PR. Below is a spec to insert into // Regression: the operator that re-requests stale data sits behind a skip that
// removes stale data. These two specs drive the real pipeline — note that they
// deliberately do NOT stub reRequestStaleRemoteData, unlike every other spec in
// this file, because the interaction between it and the skipWhile immediately in
// front of it is exactly what is broken.
describe(`findByHref with an entry that goes stale while in flight`, () => {
beforeEach(() => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
});
it(`should re-request the stale RemoteData`, () => {
testScheduler.run(({ cold }) => {
// What a caller subscribing after the invalidation sees: the request it
// is waiting on was marked stale while still in flight, and then
// completed stale. There is no non-stale value in the stream, so the
// skipWhile never stops skipping.
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', {
a: remoteDataMocks.ResponsePendingStale,
b: remoteDataMocks.SuccessStale,
}));
service.findByHref(selfLink, true, true, ...linksToFollow).subscribe();
});
// createAndSendGetRequest is called once by the original findByHref, and
// once more by each re-request. A count of 1 therefore means nothing
// re-requested the stale data, and the caller waits for ever.
expect((service as any).createAndSendGetRequest.calls.count()).toBeGreaterThan(1);
});
it(`should not emit the stale RemoteData to the caller`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', {
a: remoteDataMocks.ResponsePendingStale,
b: remoteDataMocks.SuccessStale,
}));
// The skip exists to stop stale cached data reaching consumers, and it
// must keep doing that: re-requesting is not a licence to leak.
expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe('---');
});
});
});A few notes from my coding agent about this:
|


PR: Fix infinite route-loader spinner on navigation (stale root-endpoint cache deadlock)
Target branch:
DSpace/dspace-angular:mainRelates to: #3584, #3697
Fixes #5855
Description
BrowserInitServiceinvalidates the root API endpoint cache on everyNavigationStart. On a subsequent request,HALEndpointService.getEndpointMapAt(hit on essentially every request via
getEndpoint()) discards the now-staleroot
/server/apientry and triggers a re-fetch that can deadlock — theroute resolver never completes,
NavigationEndnever fires, and theds-base-rootroute-loader spinner hangs indefinitely on a frozen store.The root endpoint map is effectively static between navigations, so
re-invalidating it on each
NavigationStartis unnecessary. Backend-downdetection still happens at init and through normal request-failure handling.
Root cause
BrowserInitService→invalidateRootCache()on everyNavigationStart./server/apiendpoint cache stale.getEndpoint()→getEndpointMapAtdiscards the stale root → re-fetchdeadlocks → resolver never resolves →
NavigationEndnever fires → spinnerhangs forever.
intermittent).
Steps to reproduce
Communities & Collections).
Reproducible on a small instance (~7k items) — not load- or scale-dependent.
Higher REST/proxy latency increases the frequency.
Fix
Remove the per-
NavigationStartinvalidateRootCache()call inbrowser-init.service.ts; keep the one-time invalidation at init. No behaviorchange to backend-down detection.
How to test
always resolves.
app still detects/handles it (init-time + request-failure paths).
Tests
Added a spec asserting the root endpoint cache is invalidated once at init
and not on subsequent
NavigationStartevents.Checklist notes
yarn lintandyarn check-circ-deps.