-
Notifications
You must be signed in to change notification settings - Fork 594
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
Implement LoadBundleTask #2296
Conversation
Coverage ReportAffected SDKs
Test Logs
NotesHTML coverage reports can be produced locally with Head commit (e486b67d) is created by Prow via merging commits: cdf06e2 86f3518. |
Binary Size ReportAffected SDKs
Test Logs
NotesHead commit (e486b67d) is created by Prow via merging commits: cdf06e2 86f3518. |
bea234e
to
ee41e57
Compare
/* package */ class LoadBundleTask extends Task<LoadBundleTaskProgress> { | ||
private final Object lock = new Object(); | ||
|
||
/** The last progress update. Null if not yet available. */ |
There was a problem hiding this comment.
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."
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "APIs" -> "API".
There was a problem hiding this comment.
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}
.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. */ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 && ...
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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...-
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()
.
There was a problem hiding this comment.
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.
There was a problem hiding this 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. */ |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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. */ |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 allexecute
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
This implements the logic for LoadBundleTask. It's basically a super advanced copy/paste job: