com.couchbase.lite.replicator.ChangeTracker Maven / Gradle / Ivy
package com.couchbase.lite.replicator;
import com.couchbase.lite.Manager;
import com.couchbase.lite.auth.Authenticator;
import com.couchbase.lite.auth.AuthenticatorImpl;
import com.couchbase.lite.internal.InterfaceAudience;
import com.couchbase.lite.util.Log;
import com.couchbase.lite.util.URIUtils;
import com.couchbase.lite.util.Utils;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthState;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.HttpClient;
import org.apache.http.client.HttpResponseException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.protocol.HttpContext;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.GZIPInputStream;
/**
* Reads the continuous-mode _changes feed of a database, and sends the
* individual change entries to its client's changeTrackerReceivedChange()
*
* @exclude
*/
@InterfaceAudience.Private
public class ChangeTracker implements Runnable {
private static final int TIMEOUT_FOR_PAUSE = 5 * 1000; // 5 sec
private URL databaseURL;
private Object lastSequenceID;
private boolean continuous = false; // is enclosing replication continuous?
private Throwable error;
private ChangeTrackerClient client;
protected Map requestHeaders;
private Authenticator authenticator;
private boolean usePOST;
private ChangeTrackerMode mode;
private String filterName;
private Map filterParams;
private int limit;
private int heartBeatSeconds;
private List docIDs;
private boolean paused = false;
private final Object pausedObj = new Object();
private boolean includeConflicts;
private Thread thread;
private boolean running = false;
private HttpUriRequest request;
private InputStream inputStream = null;
protected ChangeTrackerBackoff backoff;
private long startTime = 0;
private String str = null;
public enum ChangeTrackerMode {
OneShot,
LongPoll,
Continuous // does not work, do not use it.
}
public ChangeTracker(URL databaseURL, ChangeTrackerMode mode, boolean includeConflicts,
Object lastSequenceID, ChangeTrackerClient client) {
this.databaseURL = databaseURL;
this.mode = mode;
this.includeConflicts = includeConflicts;
this.lastSequenceID = lastSequenceID;
this.client = client;
this.requestHeaders = new HashMap();
this.heartBeatSeconds = Replication.DEFAULT_HEARTBEAT;
this.limit = 50;
}
public boolean isContinuous() {
return continuous;
}
public void setContinuous(boolean continuous) {
this.continuous = continuous;
}
public void setFilterName(String filterName) {
this.filterName = filterName;
}
public void setFilterParams(Map filterParams) {
this.filterParams = filterParams;
}
public void setClient(ChangeTrackerClient client) {
this.client = client;
}
public String getDatabaseName() {
String result = null;
if (databaseURL != null) {
result = databaseURL.getPath();
if (result != null) {
int pathLastSlashPos = result.lastIndexOf('/');
if (pathLastSlashPos > 0) {
result = result.substring(pathLastSlashPos);
}
}
}
return result;
}
public String getFeed() {
switch (mode) {
case OneShot:
return "normal";
case LongPoll:
return "longpoll";
case Continuous:
return "continuous";
}
return "normal";
}
public long getHeartbeatMilliseconds() {
return heartBeatSeconds * 1000;
}
/**
* - (NSString*) changesFeedPath
* in CBLChangeTracker.m
*/
public String getChangesFeedPath() {
if (usePOST) {
return "_changes";
}
String path = "_changes?feed=";
path += getFeed();
if (mode == ChangeTrackerMode.LongPoll) {
path += String.format("&limit=%s", limit);
}
path += String.format("&heartbeat=%s", getHeartbeatMilliseconds());
if (includeConflicts) {
path += "&style=all_docs";
}
if (lastSequenceID != null) {
path += "&since=" + URLEncoder.encode(lastSequenceID.toString());
} else {
// On first replication we can skip getting deleted docs. (SG enhancement in ver. 1.2)
path += "&active_only=true";
}
// Add filter or doc_ids:
if (docIDs != null && docIDs.size() > 0) {
filterName = "_doc_ids";
filterParams = new HashMap();
filterParams.put("doc_ids", docIDs);
}
if (filterName != null) {
path += "&filter=" + URLEncoder.encode(filterName);
if (filterParams != null) {
for (String key : filterParams.keySet()) {
Object value = filterParams.get(key);
if (!(value instanceof String)) {
try {
value = Manager.getObjectMapper().writeValueAsString(value);
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
path += '&' + URLEncoder.encode(key) + '=' + URLEncoder.encode(value.toString());
}
}
}
return path;
}
public URL getChangesFeedURL() {
String dbURLString = databaseURL.toExternalForm();
if (!dbURLString.endsWith("/")) {
dbURLString += "/";
}
dbURLString += getChangesFeedPath();
URL result = null;
try {
result = new URL(dbURLString);
} catch (MalformedURLException e) {
Log.e(Log.TAG_CHANGE_TRACKER, this + ": Changes feed ULR is malformed", e);
}
return result;
}
/**
* Set Authenticator for BASIC Authentication
*/
public void setAuthenticator(Authenticator authenticator) {
this.authenticator = authenticator;
}
@Override
public void run() {
Log.d(Log.TAG_CHANGE_TRACKER, "Thread id => " + Thread.currentThread().getId());
try {
runLoop();
} finally {
// stopped() method should be called at end of run() method.
stopped();
}
}
protected void runLoop() {
paused = false;
if (client == null) {
// This is a race condition that can be reproduced by calling cbpuller.start() and cbpuller.stop()
// directly afterwards. What happens is that by the time the Changetracker thread fires up,
// the cbpuller has already set this.client to null. See issue #109
Log.w(Log.TAG_CHANGE_TRACKER, "%s: ChangeTracker run() loop aborting because client == null", this);
return;
}
if (mode == ChangeTrackerMode.Continuous) {
// there is a failing unit test for this, and from looking at the code the Replication
// object will never use Continuous mode anyway. Explicitly prevent its use until
// it is demonstrated to actually work.
throw new RuntimeException("ChangeTracker does not correctly support continuous mode");
}
HttpClient httpClient = client.getHttpClient();
try {
backoff = new ChangeTrackerBackoff();
while (running) {
startTime = System.currentTimeMillis();
URL url = getChangesFeedURL();
if (usePOST) {
HttpPost postRequest = new HttpPost(url.toString());
postRequest.setHeader("Content-Type", "application/json");
postRequest.addHeader("User-Agent", Manager.getUserAgent());
postRequest.addHeader("Accept-Encoding", "gzip");
StringEntity entity;
try {
entity = new StringEntity(changesFeedPOSTBody());
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
postRequest.setEntity(entity);
request = postRequest;
} else {
request = new HttpGet(url.toString());
}
addRequestHeaders(request);
// Perform BASIC Authentication if needed
boolean isUrlBasedUserInfo = false;
// If the URL contains user info AND if this a DefaultHttpClient then preemptively set the auth credentials
String userInfo = url.getUserInfo();
if (userInfo != null) {
isUrlBasedUserInfo = true;
} else {
if (authenticator != null) {
AuthenticatorImpl auth = (AuthenticatorImpl) authenticator;
userInfo = auth.authUserInfo();
}
}
if (userInfo != null) {
if (userInfo.contains(":") && !":".equals(userInfo.trim())) {
String[] userInfoElements = userInfo.split(":");
String username = isUrlBasedUserInfo ? URIUtils.decode(userInfoElements[0]) : userInfoElements[0];
String password = isUrlBasedUserInfo ? URIUtils.decode(userInfoElements[1]) : userInfoElements[1];
final Credentials credentials = new UsernamePasswordCredentials(username, password);
if (httpClient instanceof DefaultHttpClient) {
DefaultHttpClient dhc = (DefaultHttpClient) httpClient;
HttpRequestInterceptor preemptiveAuth = new HttpRequestInterceptor() {
@Override
public void process(HttpRequest request, HttpContext context) throws HttpException, IOException {
AuthState authState = (AuthState) context.getAttribute(ClientContext.TARGET_AUTH_STATE);
if (authState.getAuthScheme() == null) {
authState.setAuthScheme(new BasicScheme());
authState.setCredentials(credentials);
}
}
};
dhc.addRequestInterceptor(preemptiveAuth, 0);
}
} else {
Log.w(Log.TAG_CHANGE_TRACKER, "RemoteRequest Unable to parse user info, not setting credentials");
}
}
try {
String maskedRemoteWithoutCredentials = getChangesFeedURL().toString();
maskedRemoteWithoutCredentials = maskedRemoteWithoutCredentials.replaceAll("://.*:.*@", "://---:---@");
Log.v(Log.TAG_CHANGE_TRACKER, "%s: Making request to %s", this, maskedRemoteWithoutCredentials);
HttpResponse response = httpClient.execute(request);
StatusLine status = response.getStatusLine();
// In case response status is Error, ChangeTracker stops here
// except mode is LongPoll and error is transient.
if (status.getStatusCode() >= 300 &&
((mode == ChangeTrackerMode.LongPoll && !Utils.isTransientError(status)) ||
mode != ChangeTrackerMode.LongPoll)) {
Log.e(Log.TAG_CHANGE_TRACKER, "%s: Change tracker got error %d", this, status.getStatusCode());
this.error = new HttpResponseException(status.getStatusCode(), status.getReasonPhrase());
HttpEntity entity = response.getEntity();
if (entity != null) {
try {
entity.consumeContent();
} catch (IOException e) {
}
}
break;
}
// Parse response body
HttpEntity entity = response.getEntity();
try {
Log.v(Log.TAG_CHANGE_TRACKER, "%s: got response. status: %s mode: %s", this, status, mode);
if (entity != null) {
try {
Log.v(Log.TAG_CHANGE_TRACKER, "%s: /entity.getContent(). mode: %s", this, mode);
inputStream = entity.getContent();
// decompress if contentEncoding is gzip
if (Utils.isGzip(entity))
inputStream = new GZIPInputStream(inputStream);
if (mode == ChangeTrackerMode.LongPoll) { // continuous replications
// NOTE: 1. check content length, ObjectMapper().readValue() throws Exception if size is 0.
// NOTE: 2. HttpEntity.getContentLength() returns the number of bytes of the content, or a negative number if unknown.
// NOTE: 3. If Http Status is error, not parse response body
boolean responseOK = false; // default value
if (entity.getContentLength() != 0 && status.getStatusCode() < 300) {
try {
Log.v(Log.TAG_CHANGE_TRACKER, "%s: readValue", this);
Map fullBody = Manager.getObjectMapper().readValue(inputStream, Map.class);
Log.v(Log.TAG_CHANGE_TRACKER, "%s: /readValue. fullBody: %s", this, fullBody);
responseOK = receivedPollResponse(fullBody);
} catch (JsonParseException jpe) {
Log.w(Log.TAG_CHANGE_TRACKER, "%s: json parsing error; %s", this, jpe.toString());
}
}
Log.v(Log.TAG_CHANGE_TRACKER, "%s: responseOK: %s", this, responseOK);
if (responseOK) {
// TODO: this logic is questionable, there's lots
// TODO: of differences in the iOS changetracker code,
client.changeTrackerCaughtUp();
Log.v(Log.TAG_CHANGE_TRACKER, "%s: Starting new longpoll", this);
backoff.resetBackoff();
continue;
} else {
long elapsed = (System.currentTimeMillis() - startTime) / 1000;
Log.w(Log.TAG_CHANGE_TRACKER, "%s: Longpoll connection closed (by proxy?) after %d sec", this, elapsed);
if (elapsed >= 30) {
// Looks like the connection got closed by a proxy (like AWS' load balancer) while the
// server was waiting for a change to send, due to lack of activity.
// Lower the heartbeat time to work around this, and reconnect:
this.heartBeatSeconds = Math.min(this.heartBeatSeconds, (int) (elapsed * 0.75));
Log.v(Log.TAG_CHANGE_TRACKER, "%s: Starting new longpoll", this);
backoff.resetBackoff();
continue;
} else {
Log.d(Log.TAG_CHANGE_TRACKER, "%s: Change tracker calling stop (LongPoll)", this);
client.changeTrackerFinished(this);
break;
}
}
} else { // one-shot replications
Log.v(Log.TAG_CHANGE_TRACKER, "%s: readValue (oneshot)", this);
JsonFactory factory = new JsonFactory();
JsonParser jp = factory.createParser(inputStream);
JsonToken token;
// nextToken() is null => no more token
while (((token = jp.nextToken()) != JsonToken.START_ARRAY) &&
(token != null)) {
// ignore these tokens
}
while (jp.nextToken() == JsonToken.START_OBJECT) {
Map change = (Map) Manager.getObjectMapper().readValue(jp, Map.class);
if (!receivedChange(change)) {
Log.w(Log.TAG_CHANGE_TRACKER, "Received unparseable change line from server: %s", change);
}
// if not running state anymore, exit from loop.
if (!running)
break;
}
if (jp != null) {
jp.close();
}
Log.v(Log.TAG_CHANGE_TRACKER, "%s: /readValue (oneshot)", this);
client.changeTrackerCaughtUp();
if (isContinuous()) { // if enclosing replication is continuous
mode = ChangeTrackerMode.LongPoll;
} else {
Log.d(Log.TAG_CHANGE_TRACKER, "%s: Change tracker calling stop (OneShot)", this);
client.changeTrackerFinished(this);
break;
}
}
backoff.resetBackoff();
} finally {
try {
if (inputStream != null) {
inputStream.close();
inputStream = null;
}
} catch (IOException e) {
}
}
}
} finally {
if (entity != null) {
try {
entity.consumeContent();
} catch (IOException e) {
}
}
}
} catch (Exception e) {
if (!running && e instanceof IOException) {
// in this case, just silently absorb the exception because it
// frequently happens when we're shutting down and have to
// close the socket underneath our read.
} else {
Log.w(Log.TAG_CHANGE_TRACKER, this + ": Exception in change tracker", e);
this.error = e;
}
backoff.sleepAppropriateAmountOfTime();
}
}
} finally {
// shutdown connection manager (close all connections)
if (httpClient != null && httpClient.getConnectionManager() != null)
httpClient.getConnectionManager().shutdown();
}
Log.v(Log.TAG_CHANGE_TRACKER, "%s: Change tracker run loop exiting", this);
}
public boolean receivedChange(final Map change) {
// wait if paused flag is on.
waitIfPaused();
// check if still running
if (running) {
Object seq = change.get("seq");
if (seq == null) {
return false;
}
//pass the change to the client on the thread that created this change tracker
if (client != null) {
Log.d(Log.TAG_CHANGE_TRACKER, "%s: changeTrackerReceivedChange: %s", this, change);
client.changeTrackerReceivedChange(change);
Log.d(Log.TAG_CHANGE_TRACKER, "%s: /changeTrackerReceivedChange: %s", this, change);
}
lastSequenceID = seq;
}
return true;
}
public boolean receivedPollResponse(Map response) {
List
© 2015 - 2025 Weber Informatics LLC | Privacy Policy