Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Search] Add cancel function to pollSearch #85787

Merged
merged 21 commits into from
Jan 14, 2021
Merged

Conversation

lizozom
Copy link
Contributor

@lizozom lizozom commented Dec 14, 2020

Summary

Resolves #85603

Makes sure that Elasticsearch DELETE requests for async searches are properly sent on abort, even if consumer unsubscribes, both from client and server.

Checklist

Delete any items that are not applicable to this PR.

For maintainers

Makes sure that DELETE requests are properly sent even if consumer unsubscribes.
@lizozom lizozom added bug Fixes for quality problems that affect the customer experience blocker v8.0.0 release_note:skip Skip the PR/issue when compiling release notes v7.11.0 labels Dec 14, 2020
@lizozom lizozom self-assigned this Dec 14, 2020
@lizozom lizozom requested a review from a team as a code owner December 14, 2020 14:53
@lizozom lizozom removed the blocker label Dec 14, 2020
Copy link
Member

@lukasolson lukasolson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While playing around with this, I realized we have another issue with pollSearch. It doesn't defer, which means the search function will be called (just once) before there are any subscribers. Do you mind wrapping it in a defer and adding a test case for this?

Copy link
Member

@weltenwort weltenwort left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The de-duplication of the cancel looks elegant. 👍

Unfortunately these changes don't quite make the triggering of the abortSignal and the unsubscription commutative. I left a suggestion for another variant in the comments.

(I'm using the Logs UI's log entry details flyout as a test-bed for verification.)

Thanks for following up on the issue!

Comment on lines 20 to 38
const aborted = options?.abortSignal
? abortSignalToPromise(options?.abortSignal)
: { promise: new Promise(() => {}), cleanup: () => {} };

return from(search()).pipe(
expand(() => timer(pollInterval).pipe(switchMap(search))),
tap((response) => {
if (isErrorResponse(response)) throw new AbortError();
}),
takeWhile<Response>(isPartialResponse, true),
takeUntil<Response>(from(aborted.promise)),
finalize(aborted.cleanup)
);
aborted.promise.catch(() => {
if (cancel) cancel();
});

return from(search()).pipe(
expand(() => timer(pollInterval).pipe(switchMap(search))),
tap((response) => {
if (isErrorResponse(response)) {
throw response ? new Error('Received partial response') : new AbortError();
}
}),
takeWhile<Response>(isPartialResponse, true),
takeUntil<Response>(from(aborted.promise)),
finalize(aborted.cleanup)
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately this still prevents cancel call from being called if the consumer unsubscribes from the returned Observable before the abortSignal is triggered. The finalize unregisters the aborted.promise from the abortSignal.

How about the following?

export const pollSearch = <Response extends IKibanaSearchResponse>(
  search: () => Promise<Response>,
  cancel?: () => void,
  { pollInterval = 1000, abortSignal }: IAsyncSearchOptions = {}
): Observable<Response> => {
  return defer(() => {
    if (abortSignal?.aborted) {
      return EMPTY;
    }

    if (cancel && abortSignal) {
      abortSignal.addEventListener('abort', cancel, { once: true });
    }

    const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe(
      map(() => {
        throw new AbortError();
      })
    );

    return from(search()).pipe(
      expand(() => timer(pollInterval).pipe(switchMap(search))),
      tap((response) => {
        if (isErrorResponse(response)) {
          throw response ? new Error('Received partial response') : new AbortError();
        }
      }),
      takeWhile<Response>(isPartialResponse, true),
      takeUntil<Response>(aborted$)
    );
  });
};

This seems to work and avoids an unnecessary intermediate Promise. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this result in a memory leak since it is never removing the event listener from the abortSignal?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suspect not, because nothing holds a reference to the abort signal once the observable goes out of scope. The gc should be able to clean it up afterwards.

catchError((e: AbortError) => {
if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`);
catchError((e: Error) => {
cancel();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The de-duplication is neat, but we have to guard against calling this when the abortSignal has been triggered, because that has already caused the DELETE request to be sent.

Suggested change
cancel();
if (!combinedSignal.aborted) {
cancel();
}

Otherwise it will be sent twice on explicit abort.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukasolson are you still considering to fix this scenario?

@tsullivan tsullivan self-requested a review December 22, 2020 18:47
@tsullivan
Copy link
Member

Hi @lizozom,

Any tips on how reviewers can test this?

Any of the checklist items need to be checked off or removed?

@weltenwort
Copy link
Member

@tsullivan I've been using the Logs UI "log entry details fly-out" to test:

image

It shows a progress bar, offers an explicit "cancel" button and cancels implicitly when the fly-out is closed while loading. It represents a more realistic usage with varying sequences of events (subscribe, unsubscribe, abort, ...) that unit tests don't cover.

Copy link
Member

@tsullivan tsullivan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the code change in a plugin, and found that if the consumer unsubscribes the search observable early, then the DELETE request is not sent.

If the abort signal is called explicitly, then the DELETE request is sent twice as @weltenwort noted in their comment.

@lukasolson
Copy link
Member

@elasticmachine merge upstream

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

Metrics [docs]

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
dataEnhanced 27.4KB 27.4KB +1.0B

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
dataEnhanced 39.4KB 38.9KB -477.0B

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

Copy link
Member

@weltenwort weltenwort left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellation seems to work reliably in all situations I tested. Thanks for hanging in there! 👍

@@ -83,9 +84,9 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
isSavedToBackground = true;
});

const cancel = () => {
const cancel = once(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elegant 👍

@lukasolson lukasolson merged commit 20bf774 into elastic:master Jan 14, 2021
lukasolson added a commit to lukasolson/kibana that referenced this pull request Jan 14, 2021
* Add cancel functionality to pollSearch
Makes sure that DELETE requests are properly sent even if consumer unsubscribes.

* Update poll_search.test.ts

* cancel on abort

* fix jest

* ADded jest test

* Cancel to be called once

* Only cancel internally on abort

* ts + addd defer

* ts

* make cancel code prettier

* Cancel even after unsubscribe

* Throw abort error if already aborted

* Only delete once

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>
lukasolson added a commit that referenced this pull request Jan 15, 2021
* Add cancel functionality to pollSearch
Makes sure that DELETE requests are properly sent even if consumer unsubscribes.

* Update poll_search.test.ts

* cancel on abort

* fix jest

* ADded jest test

* Cancel to be called once

* Only cancel internally on abort

* ts + addd defer

* ts

* make cancel code prettier

* Cancel even after unsubscribe

* Throw abort error if already aborted

* Only delete once

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>

Co-authored-by: Liza Katz <lizka.k@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Fixes for quality problems that affect the customer experience release_note:skip Skip the PR/issue when compiling release notes v7.12.0 v8.0.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Cancellation of search requests are not reliably forwarded to the server-side search strategy
5 participants