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

Implement LoadBundleTask #2296

Merged
merged 7 commits into from
Jan 8, 2021
Merged

Implement LoadBundleTask #2296

merged 7 commits into from
Jan 8, 2021

Conversation

schmidt-sebastian
Copy link
Contributor

This implements the logic for LoadBundleTask. It's basically a super advanced copy/paste job:

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Jan 6, 2021

Coverage Report

Affected SDKs

  • firebase-firestore

    SDK overall coverage changed from 46.83% (cdf06e2) to 47.11% (e486b67d) by +0.29%.

    Filename Base (cdf06e2) Head (e486b67d) Diff
    GrpcCallProvider.java 68.18% 57.95% -10.23%
    LoadBundleTask.java 0.00% 82.80% +82.80%
    LoadBundleTaskProgress.java 0.00% 74.29% +74.29%
    OnProgressListener.java ? 0.00% ?
  • firebase-storage

    SDK overall coverage changed from 85.52% (cdf06e2) to 85.83% (e486b67d) by +0.30%.

    Filename Base (cdf06e2) Head (e486b67d) Diff

Test Logs

Notes

HTML coverage reports can be produced locally with ./gradlew <product>:checkCoverage.
Report files are located at <product-build-dir>/reports/jacoco/.

Head commit (e486b67d) is created by Prow via merging commits: cdf06e2 86f3518.

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Jan 6, 2021

Binary Size Report

Affected SDKs

  • firebase-common

    Type Base (cdf06e2) Head (e486b67d) Diff
    aar 50.1 kB 53.8 kB +3.62 kB (+7.2%)
    apk (release) 635 kB 636 kB +860 B (+0.1%)
  • firebase-firestore

    Type Base (cdf06e2) Head (e486b67d) Diff
    aar 1.04 MB 1.04 MB +5.03 kB (+0.5%)
    apk (release) 3.16 MB 3.16 MB +776 B (+0.0%)
  • firebase-storage

    Type Base (cdf06e2) Head (e486b67d) Diff
    aar 119 kB 116 kB -3.59 kB (-3.0%)
    apk (release) 961 kB 961 kB -8 B (-0.0%)

Test Logs

Notes

Head commit (e486b67d) is created by Prow via merging commits: cdf06e2 86f3518.

/* package */ class LoadBundleTask extends Task<LoadBundleTaskProgress> {
private final Object lock = new Object();

/** The last progress update. Null if not yet available. */
Copy link
Contributor

Choose a reason for hiding this comment

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

Since "Null" is a case-sensitive keyword, I suggest fixing it to the correct case. You could even just say

"The last progress update, or {@code null} if not yet available."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (used your sugestion)

private final TaskCompletionSource<LoadBundleTaskProgress> completionSource;

/**
* A delegate task derived from `completionSource`. All APIs events that don't involve progress
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: "APIs" -> "API".

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, Javadoc does not do markdown processing, so `completionSource` should be {@code completionSource}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done and done.


/** A queue of active progress listeners. */
@GuardedBy("lock")
private final Queue<ManagedListener> progressListenerQueue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is Queue chosen as the container, instead of, say, ArrayList? It looks like the main benefits of a queue (optimized operations for adding to the beginning and removing from the end) are not utilized. If Queue is the data structure you want, consider renaming the variable from progressListenerQueue to just progressListeners since the fact that it's a queue is not particularly meaningful. Also, consider just making this object's type ArrayDeque since the Queue abstraction is not really used.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I chose these datastructures based on the implementation in TaskImpl (cs/piper///depot/google3/java/com/google/android/gmscore/integ/client/tasks/src/com/google/android/gms/tasks/TaskCompletionListenerQueue.java;l=20;rcl=350262826), which follows the same pattern as implemented here. There are a couple different datastructures that I could use here, but I'd rather follow an existing pattern, especially when the source is this trustworthy.

I renamed the variable to progressListeners.

}

/** Returns {@code true} if the Task has completed successfully; {@code false} otherwise. */
Copy link
Contributor

Choose a reason for hiding this comment

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

"otherwise" is somewhat ambiguous here. What does it return if the task has not completed? Please document this explicitly. This also applies to the documentation of isCanceled() below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do see your suggestion as an improvement, but I would like to push back for now since this is the same documentation that is used in the base class: https://developer.android.com/reference/com/google/android/play/core/tasks/Task#iscomplete

/**
* Adds a listener that is called if the Task completes successfully. The listener will be called
* on the main application thread. If the task has already completed successfully, a call to the
* listener will be immediately scheduled. If multiple listeners are added, they will be called in
Copy link
Contributor

Choose a reason for hiding this comment

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

"immediately scheduled" is somewhat ambiguous. Does it mean that it will be called synchronously or that it will be scheduled to be called from the main thread asynchronously? Please elaborate in the comment. Please make any changes to all adaptations of this sentence in the methods that follow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same response as above: I would like to keep the documentation in sync with the original API.


LoadBundleTaskProgress that = (LoadBundleTaskProgress) o;

if (documentsLoaded != that.documentsLoaded) return false;
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider writing this as a big chain of logical ands.

e.g. return documentsLoaded == that.documentsLoaded && totalDocuments == that.totalDocuments && ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is auto-generated so I will leave as is.

@Override
public int hashCode() {
int result = documentsLoaded;
result = 31 * result + totalDocuments;
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using Objects.hash() from the standard library instead of implementing your own hash code.

https://docs.oracle.com/javase/8/docs/api/java/util/Objects.html#hash-java.lang.Object...-

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not available with Android 14 (requires API level 19+).

@@ -27,39 +38,79 @@
private int totalDocuments;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should totalDocuments, bytesLoaded, and totalBytes be declared as final as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, good catch.

@@ -38,4 +58,208 @@ public void testImplementsAllTaskInterface() {
}
}
}

@Test
public void testSuccessListener() throws InterruptedException {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add tests to verify that the correct thread is used to perform the callbacks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

synchronized (lock) {
snapshot = result;
for (ManagedListener progressListener : progressListenerQueue) {
progressListener.maybeRun(result);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this code will throw ConcurrentModificationException if the callback adds or removes a progress listener. Please add a test for this and verify that this scenario is handled correctly. This comment also applies to setException() and updateProgress().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should work because of how this will be called, since this method will be invoked from the AsyncQueue and the other methods from the Main thread. This is pretty fragile though, so I updated it and added a test.

Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian left a comment

Choose a reason for hiding this comment

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

Thanks for the thorough review. Feedback addressed.

/* package */ class LoadBundleTask extends Task<LoadBundleTaskProgress> {
private final Object lock = new Object();

/** The last progress update. Null if not yet available. */
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (used your sugestion)

private final TaskCompletionSource<LoadBundleTaskProgress> completionSource;

/**
* A delegate task derived from `completionSource`. All APIs events that don't involve progress
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done and done.


/** A queue of active progress listeners. */
@GuardedBy("lock")
private final Queue<ManagedListener> progressListenerQueue;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I chose these datastructures based on the implementation in TaskImpl (cs/piper///depot/google3/java/com/google/android/gmscore/integ/client/tasks/src/com/google/android/gms/tasks/TaskCompletionListenerQueue.java;l=20;rcl=350262826), which follows the same pattern as implemented here. There are a couple different datastructures that I could use here, but I'd rather follow an existing pattern, especially when the source is this trustworthy.

I renamed the variable to progressListeners.

}

/** Returns {@code true} if the Task has completed successfully; {@code false} otherwise. */
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do see your suggestion as an improvement, but I would like to push back for now since this is the same documentation that is used in the base class: https://developer.android.com/reference/com/google/android/play/core/tasks/Task#iscomplete

/**
* Adds a listener that is called if the Task completes successfully. The listener will be called
* on the main application thread. If the task has already completed successfully, a call to the
* listener will be immediately scheduled. If multiple listeners are added, they will be called in
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same response as above: I would like to keep the documentation in sync with the original API.


/** If the task failed, returns the exception. Otherwise, returns null. */
@Nullable
public Exception getError() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was trying to use names in the Task API, but failed miserably :) It's also Task#getException


LoadBundleTaskProgress that = (LoadBundleTaskProgress) o;

if (documentsLoaded != that.documentsLoaded) return false;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is auto-generated so I will leave as is.

if (totalDocuments != that.totalDocuments) return false;
if (bytesLoaded != that.bytesLoaded) return false;
if (totalBytes != that.totalBytes) return false;
return taskState == that.taskState;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops. Forgot to regenerate after adding the exception (which we also have to add to the API document)

cc @wu-hui

@Override
public int hashCode() {
int result = documentsLoaded;
result = 31 * result + totalDocuments;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not available with Android 14 (requires API level 19+).

@@ -38,4 +58,208 @@ public void testImplementsAllTaskInterface() {
}
}
}

@Test
public void testSuccessListener() throws InterruptedException {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link
Contributor

@dconeybe dconeybe left a comment

Choose a reason for hiding this comment

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

LGTM, with a few optional/minor suggestions.

completionSource.setException(exception);
}

void updateProgress(LoadBundleTaskProgress snapshot) {
void updateProgress(LoadBundleTaskProgress progressUpdate) {
Queue<ManagedListener> currentListeners;
Copy link
Contributor

@dconeybe dconeybe Jan 8, 2021

Choose a reason for hiding this comment

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

How frequently is updateProgress() called? If often (e.g. more than 10 times per second) then object creation on each progress update (i.e. creating the new ArrayDeque) can thrash GC.

One pattern I've used in the past to address this is to store the listeners directly in an array. Before making a callback you grab the reference to the array, store it in a local variable, then iterate over it and notify each listener. When a listener is added or removed then you create a new array instead of modifying the array in-place. As long as adding/removing listeners is rare relative to the notifications then it is a win because no object allocation is done to make the callbacks.

I have another idea that is somewhat out of scope but related to this idea of thrashing GC. It is a very optional suggestion. Could you make LoadBundleTaskProgress a mutable object and simply specify the same instance to each progress update? That would avoid having to allocate a new object for each progress update and since all callbacks should just get the most recent data anyways it could be a win.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought about this more: Since I am always invoking the callback on an executor, there should only be two scenarios:

  • updateProgress is executed on the same thread as the callbacks. This means that all execute calls will only run after the loop has finished processing.
  • updateProgress is executed on a different thread from the callbacks. This means that the other thread won't be able to grab the lock to modify the progress listener.

With that, I am reasonably confident that we don't need the extra copy and can simplify this quite a bit.

(FYI - I could implement your suggestion above by changing the data structure to our internal ImmutableSet/Map implementation, but I do still prefer to keep in sync with Android's Task API.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ahh yes that makes sense. I missed the fact that the callbacks were being enqueued on an executor. In that case, I rescind my comment about the ConcurrentModificationException.

task.addOnProgressListener(
p -> {
assertNotEquals(TEST_THREAD_NAME, Thread.currentThread().getName());
latch.countDown();
Copy link
Contributor

Choose a reason for hiding this comment

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

You may want to call latch.countDown() before assertNotEquals(); otherwise, if the assertion fails then the latch will never count down and the test will hang. Applies here and elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Swapping these will allow the test to pass before the assertions trigger (as the scope of the test is only bound by the latches). I think what we need to do is add try/finally blocks.

LoadBundleTask task = new LoadBundleTask();
task.addOnProgressListener(
p -> {
assertNotEquals(TEST_THREAD_NAME, Thread.currentThread().getName());
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you confirm that this assertion failure will actually cause a test failure? Since the exception will be thrown on some other thread I just don't know if it will cause the test to fail.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The test shows the stacktrace, but doesn't fail. I added a super ugly hack to ensure the test fails when this happens. Let me know if you can think of something better.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are you able to use org.junit.rules.ErrorCollector? If so, I think it would be a perfect fit for reporting assertion failures from other threads.

https://junit.org/junit4/javadoc/latest/org/junit/rules/ErrorCollector.html

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I saw this when I googled around, but it didn't quite strike my mind that this is exactly what I need. Thanks!

@schmidt-sebastian schmidt-sebastian merged commit 76597db into master Jan 8, 2021
schmidt-sebastian added a commit that referenced this pull request Jan 9, 2021
schmidt-sebastian added a commit that referenced this pull request Jan 9, 2021
andreaowu pushed a commit that referenced this pull request Jan 29, 2021
@firebase firebase locked and limited conversation to collaborators Feb 8, 2021
@kaibolay kaibolay deleted the mrschmidt/bundle/task branch September 14, 2022 17:56
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants