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

Fix soft TTL for duplicate requests #73

Merged
merged 10 commits into from
Sep 12, 2017
Merged

Conversation

uhager
Copy link
Contributor

@uhager uhager commented Aug 14, 2017

When a soft TTL cache entry exists, that response should be served immediately rather than having duplicate requests wait for the first request's network response. This is done by moving the list of waiting, duplicate requests from the RequestQueue to the CacheDispatcher.

uhager added 3 commits August 14, 2017 15:47
…ts, that response should be served immediately rather than having duplicate requests wait for the first request's network response.
…ts, that response should be served immediately rather than having duplicate requests wait for the first request's network response.
…ts, that response should be served immediately rather than having duplicate requests wait for the first request's network response.
@jpd236 jpd236 requested a review from yilanl August 15, 2017 22:36
@jpd236 jpd236 requested a review from jjoslin September 8, 2017 20:16
Copy link
Collaborator

@jjoslin jjoslin left a comment

Choose a reason for hiding this comment

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

Looks good overall, just some minor concurrency issues to address.

try {
mNetworkQueue.put(request);
} catch (InterruptedException e) {
// Not much we can do about this.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't just swallow the InterruptedException, reset the interrupted status on the thread.

try {
mNetworkQueue.put(nextInLine);
} catch (InterruptedException iex) {
VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't just swallow the InterruptedException, reset the interrupted status on the thread.

synchronized (mWaitingRequests) {
waitingRequests = mWaitingRequests.remove(cacheKey);
}
if (waitingRequests != null) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is racy and you should expand the synchronized block above to also include the mWaitingRequests.put() call below. If you don't then the map can be modified by maybeAddToWaitingRequests() before the code hits the synchronized block below and any changes will be clobbered.

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.

* @param response received from the network
*/
/* package */ void notifyListenerResponseReceived(Response<?> response) {
if (mRequestCompleteListener != null) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Access to mRequestCompleteListener should be synchronized. I believe the code works in its current state because mRequestCompleteListener is never concurrently accessed but if the code is ever rearranged then this could be a problem.

Copy link
Contributor

@joebowbeer joebowbeer left a comment

Choose a reason for hiding this comment

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

Great to see new unit tests. What is the code coverage of the added code?

* is <em>not</em> contained in that list. Is null if no requests are staged.</li>
* </ul>
*/
private final Map<String, Queue<Request<?>>> mWaitingRequests = new HashMap<>();
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 remove explicit synchronization of waiting requests and replace with ConcurrentHashMap instance? The downside is that you cannot use null as a marker, but that may be a good thing:-)

Is the CHM putIfAbsent method useful?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should probably defer discussion of that kind of change to a separate PR / bug if desired. This portion of the code was just moved from one class to another, and to keep the scope of this PR manageable we should try to focus it solely on the bug it is trying to fix rather than introduce lots of other small fixes. (I do not think this would be a trivial change; often ConcurrentHashMap has significantly worse performance than a simple HashMap with a lock around it for low levels of concurrency).

Copy link
Contributor

Choose a reason for hiding this comment

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

@jpd236 Understood. Every time I see synchronized added, it raises my concern about maintainability, correctness and potential deadlock. I doubt ConcurrentHashMap would have any negative impact on the performance of this method at any level of concurrency. It might lead to a design that is easier to understand, though, and avoid questions about correctness. I felt obliged to raise this issue but I won't push back.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't we still have to add a synchronized block in onNoUsableResponseReceived anyway?

Copy link
Contributor

@joebowbeer joebowbeer Sep 10, 2017

Choose a reason for hiding this comment

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

@uhager synchronized can always be avoided :-) java.util.concurrent was written without a single synchronized. My advice to use CHM may be a bum lead, but the motivation is to make the synchronization more local and explicit. CHM may not be the best way to do that, but it feels like it may be. Seeing synchronized added to the code without explanation of which threads are involved, etc., worries me. Would moving the listener methods to a nested delegate, as I suggest, also allow the new synchronization to be more localized?

// There is already a request in flight. Queue up.
Queue<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
if (stagedRequests == null) {
stagedRequests = new LinkedList<Request<?>>();
Copy link
Contributor

Choose a reason for hiding this comment

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

ArrayList is more performant here (and virtually everywhere). In other words, LinkedList is best avoided in Java.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This one was also just copied from the old code... but in this case I'm fine with making the change as I do think it's reasonable enough.

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 a Queue though. Should I make the Queue a List instead?

Copy link
Contributor

Choose a reason for hiding this comment

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

@uhager Ah. Use ArrayDeque:

This class is likely to be faster than Stack when used as a stack,
and faster than LinkedList when used as a queue.

Null items are prohibited. I hope that's not a problem.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Volley's minSdkVersion is still 8, and ArrayDeque was added in API 9. Definitely a reasonable change to consider bumping minSdkVersion and making this change but it'd be out of the scope of the CL. So probably best to leave as is for now.

*/
/* package */ void setNetworkRequestCompleteListener(
NetworkRequestCompleteListener requestCompleteListener) {
synchronized(mLock) {
Copy link
Contributor

Choose a reason for hiding this comment

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

code style nit:

synchronized (mLock) {

@Override
public void onNoUsableResponseReceived(Request<?> request) {
String cacheKey = request.getCacheKey();
Queue<Request<?>> waitingRequests;
Copy link
Contributor

Choose a reason for hiding this comment

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

Move this to where it is assigned.

VolleyLog.v("%d waiting requests for cacheKey=%s; resend to network",
waitingRequests.size(), cacheKey);
}
Request<?> nextInLine = waitingRequests.remove();
Copy link
Contributor

Choose a reason for hiding this comment

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

How do you know nextInLine is not null? (A BlockingQueue does not accept null elements.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a check.

Copy link
Collaborator

@jpd236 jpd236 left a comment

Choose a reason for hiding this comment

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

Regarding code coverage: we don't have that measured in our build at the moment, but it might be a reasonable feature request as a separate bug just for the extra information. (That being said, I don't think code coverage is a be-all end-all metric for test quality / sufficiency).

* is <em>not</em> contained in that list. Is null if no requests are staged.</li>
* </ul>
*/
private final Map<String, Queue<Request<?>>> mWaitingRequests = new HashMap<>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should probably defer discussion of that kind of change to a separate PR / bug if desired. This portion of the code was just moved from one class to another, and to keep the scope of this PR manageable we should try to focus it solely on the bug it is trying to fix rather than introduce lots of other small fixes. (I do not think this would be a trivial change; often ConcurrentHashMap has significantly worse performance than a simple HashMap with a lock around it for low levels of concurrency).

// There is already a request in flight. Queue up.
Queue<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
if (stagedRequests == null) {
stagedRequests = new LinkedList<Request<?>>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

This one was also just copied from the old code... but in this case I'm fine with making the change as I do think it's reasonable enough.

@jpd236
Copy link
Collaborator

jpd236 commented Sep 10, 2017

Also, @joebowbeer, thanks so much for the review! We appreciate your help :)

@Override
public void run() {
try {
mNetworkQueue.put(request);
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wondering: What thread executes this blocking call? (I realize that this is just moving code around.)

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'm not quite sure which call you are referring to, but the CacheDispatcher runs on its own thread. In the default implementation, the Runnable is executed in ExecutorDelivery, which runs it on a thread that is set in ExecutorDelivery's instantiation by RequestQueue, where the default is the main looper. The network dispatcher then runs again on its own thread when it grabs the request from the NetworkQueue.

Copy link
Contributor

Choose a reason for hiding this comment

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

@uhager I was referring to the mNetworkQueue.put which is a blocking call (hence the 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.

Ah, that one then runs on the main thread (when using the default RequestQueue with the default ExecutorDelivery).

@@ -29,7 +33,7 @@
* refresh are enqueued on the specified network queue for processing
* by a {@link NetworkDispatcher}.
*/
public class CacheDispatcher extends Thread {
public class CacheDispatcher extends Thread implements Request.NetworkRequestCompleteListener {
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 push this listener responsibility into a delegate? Best practice is generally to limit scope/capability as much as possible... I bring this up because it is confusing to me to see new methods added to this Thread class that do not execute on this Thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you mean as a nested class in CacheDispatcher?

Copy link
Contributor

@joebowbeer joebowbeer Sep 10, 2017

Choose a reason for hiding this comment

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

@uhager Yes. Move the listener methods, the mWaitingRequests instance and the maybeAdd helper method to a static nested class with an explicit reference to the CacheDispatcher instance. Then you can just synchronize the methods of the nested class, and it will be clear what state is being maintained.

// Insert 'null' queue for this cacheKey, indicating there is now a request in
// flight.
mWaitingRequests.put(cacheKey, null);
request.setNetworkRequestCompleteListener(this);
Copy link
Contributor

Choose a reason for hiding this comment

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

Make this a dedicated listener instance? (See comment above.)

@joebowbeer
Copy link
Contributor

joebowbeer commented Sep 10, 2017

@jpd236 regarding code coverage, in Android Studio right-click on com.android.volley (test) and select Run Tests with Coverage. The coverage of CacheDispatcher prior to this PR was nearly 100%. It would be cool to enable this in the Travis build.

VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
// Restore the interrupted status
Thread.currentThread().interrupt();
quit();
Copy link
Contributor

Choose a reason for hiding this comment

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

This double interrupt is confusing. Worth added a comment explaining which thread is executing this listener method, and pointing out that Thread.currentThread().interrupt() is reasserting the interrupt on the calling thread, while the call to interrupt() inside quit() is interrupting the CacheDispatcher thread?

Copy link
Contributor

@joebowbeer joebowbeer left a comment

Choose a reason for hiding this comment

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

I like these changes. Suggest static nested class to make the interactions between listener and dispatcher more explicit.

@@ -48,6 +52,9 @@
/** Used for telling us to die. */
private volatile boolean mQuit = false;

/** Manage list of waiting requests and de-duplicate requests with same cache key. */
private WaitingRequestHandler mWaitingRequestHandler;
Copy link
Contributor

Choose a reason for hiding this comment

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

make this final

@@ -64,6 +71,7 @@ public CacheDispatcher(
mNetworkQueue = networkQueue;
mCache = cache;
mDelivery = delivery;
mWaitingRequestHandler = new WaitingRequestHandler();
Copy link
Contributor

Choose a reason for hiding this comment

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

Declare static nested so the interactions with CacheDispatcher are explicit: WaitingRequestHandler(this)

@@ -154,4 +172,104 @@ public void run() {
}
}
}

private class WaitingRequestHandler implements Request.NetworkRequestCompleteListener {
Copy link
Contributor

Choose a reason for hiding this comment

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

private static class - to make the interactions with CacheDispatcher explicit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for all the great comments! I'm new to Java, so I'm learning a lot here

return;
}
String cacheKey = request.getCacheKey();
Queue<Request<?>> waitingRequests;
Copy link
Contributor

Choose a reason for hiding this comment

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

declaration and assignment on single line.

Copy link
Collaborator

@jjoslin jjoslin left a comment

Choose a reason for hiding this comment

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

@uhager - the structure looks good overall and I like the introduction of the inner class. I think we can get this in as soon as the tests are passing.

@@ -64,6 +71,7 @@ public CacheDispatcher(
mNetworkQueue = networkQueue;
mCache = cache;
mDelivery = delivery;
mWaitingRequestHandler = new WaitingRequestHandler(this);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: the indentation here seems to be off by 1 space.

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
Collaborator

@jjoslin jjoslin 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 changes.

Copy link
Contributor

@joebowbeer joebowbeer left a comment

Choose a reason for hiding this comment

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

After looking at the use of Queue, I see that an ordinary List would work great, and would be more standard (meaning that's what most Java programmers would do in this situation), and then you can use the superior ArrayList class.

@jjoslin
Copy link
Collaborator

jjoslin commented Sep 11, 2017

@joebowbeer I think that's a reasonable suggestion but I'd prefer to see it done as a separate pull request.

Copy link
Collaborator

@jpd236 jpd236 left a comment

Choose a reason for hiding this comment

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

+1 to keeping that separate.

Looks good here except for a couple nits and one observation that would be good to get your thoughts on; once addressed I can merge.

@@ -48,6 +52,9 @@
/** Used for telling us to die. */
private volatile boolean mQuit = false;

/** Manage list of waiting requests and de-duplicate requests with same cache key. */
private final WaitingRequestHandler mWaitingRequestHandler;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: Handler is a loaded term on Android (https://developer.android.com/reference/android/os/Handler.html), so for clarity's sake it would be good to avoid using it if we're not actually extending that class.

Perhaps "WaitingRequestManager" or "WaitingRequestDispatcher"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Went with Manager.


/** Request received a valid response that can be used by other waiting requests. */
@Override
public synchronized void onResponseReceived(Request<?> request, Response<?> response) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

A key change here (since the last time I've looked at this) is that you are now holding a lock (via synchronized on the method) for all of the calls to postResponse. Previously it was not held during that time. I think this is probably reasonable given that a sane implementation will simply dispatch the response without actually waiting for the response to be handled (and that's the default behavior of ExecutorDelivery) but it's perhaps worth noting that a different delivery (i.e. ImmediateResponseDelivery, though this is only in the test code) would cause this lock to be held during response callback handling.

To summarize, no change requested here, but it'd be good to think through this angle one more time to make sure that there isn't a major increase in contention by holding a lock for each of these methods' entire bodies.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was going to comment on this as well: "Strive for open calls to avoid deadlock" being the best practice. In other words, don't hold a lock when calling other code.

In this case, the lock can be shrunk quite a bit:

[Hey. There are HARD tabs in this file. Is that cool?]

    Queue<Request<?>> waitingRequests;
    synchronized (this) {
        waitingRequests = mWaitingRequests.remove(cacheKey);
    }
    if (waitingRequests != null) {
        if (VolleyLog.DEBUG) {
            VolleyLog.v("Releasing %d waiting requests for cacheKey=%s.",
                    waitingRequests.size(), cacheKey);
        }
        // Process all queued up requests.
        for (Request<?> waiting : waitingRequests) {
            mCacheDispatcher.mDelivery.postResponse(waiting, response);
        }
    }

Copy link
Collaborator

@jpd236 jpd236 Sep 11, 2017

Choose a reason for hiding this comment

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

Yeah, I think that's how it was in the initial PR, so we should probably move back to that unless there is a thread-safety issue. Just need to tread real carefully there :)

And for tabs, I'm not as used to GitHub's review tool so I haven't caught whitespace issues like that, but yes, we should be using spaces and not tabs. #1 mentions integrating google-java-format into the workflow here which would resolve that more automatically FYI.

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 had that initially, but made it all synchronized for the inner class to avoid synchronized blocks. I thought that was part of the point of the nested class to have clearer synchronization by synchronizing the methods? But I can revert that.
Apologies for the tabs, I was working on that in emacs at home, I'll have to check my configurations.

Copy link
Contributor

Choose a reason for hiding this comment

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

@uhager sorry for the added confusion. The inner class separates concerns nicely, but the need for open calls adds a small complication. Thanks for addressing.

mCacheDispatcher.mNetworkQueue.put(nextInLine);
} catch (InterruptedException iex) {
VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
// Restore the interrupted status of the calling thread (i.e. NetworkDIspatcher)
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: NetworkDispatcher

@jpd236 jpd236 merged commit b2bb59a into google:master Sep 12, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants