/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package gobblin.couchbase.writer;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.avro.Schema;
import org.apache.avro.SchemaBuilder;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.commons.math3.util.Pair;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.testng.Assert;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;
import org.testng.annotations.Test;
import com.couchbase.client.deps.io.netty.buffer.ByteBuf;
import com.couchbase.client.java.Bucket;
import com.couchbase.client.java.document.AbstractDocument;
import com.couchbase.client.java.document.RawJsonDocument;
import com.couchbase.client.java.env.CouchbaseEnvironment;
import com.couchbase.client.java.env.DefaultCouchbaseEnvironment;
import com.google.gson.Gson;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import lombok.extern.slf4j.Slf4j;
import gobblin.converter.Converter;
import gobblin.converter.DataConversionException;
import gobblin.couchbase.CouchbaseTestServer;
import gobblin.couchbase.common.TupleDocument;
import gobblin.couchbase.converter.AnyToCouchbaseJsonConverter;
import gobblin.couchbase.converter.AvroToCouchbaseTupleConverter;
import gobblin.metrics.RootMetricContext;
import gobblin.metrics.reporter.OutputStreamReporter;
import gobblin.test.TestUtils;
import gobblin.writer.AsyncWriterManager;
import gobblin.writer.WriteCallback;
import gobblin.writer.WriteResponse;
@Slf4j
public class CouchbaseWriterTest {
private CouchbaseTestServer _couchbaseTestServer;
private CouchbaseEnvironment _couchbaseEnvironment;
@BeforeSuite
public void startServers() {
_couchbaseTestServer = new CouchbaseTestServer(TestUtils.findFreePort());
_couchbaseTestServer.start();
_couchbaseEnvironment = DefaultCouchbaseEnvironment.builder().bootstrapHttpEnabled(true)
.bootstrapHttpDirectPort(_couchbaseTestServer.getPort())
.bootstrapCarrierDirectPort(_couchbaseTestServer.getServerPort()).bootstrapCarrierEnabled(false)
.kvTimeout(10000).build();
}
/**
* Implement the equivalent of:
* curl -XPOST -u Administrator:password localhost:httpPort/pools/default/buckets \ -d bucketType=couchbase \
* -d name={@param bucketName} -d authType=sasl -d ramQuotaMB=200
**/
private boolean createBucket(String bucketName) {
CloseableHttpClient httpClient = HttpClientBuilder.create().build();
try {
HttpPost httpPost = new HttpPost("http://localhost:" + _couchbaseTestServer.getPort() + "/pools/default/buckets");
List<NameValuePair> params = new ArrayList<>(2);
params.add(new BasicNameValuePair("bucketType", "couchbase"));
params.add(new BasicNameValuePair("name", bucketName));
params.add(new BasicNameValuePair("authType", "sasl"));
params.add(new BasicNameValuePair("ramQuotaMB", "200"));
httpPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
//Execute and get the response.
HttpResponse response = httpClient.execute(httpPost);
log.info(String.valueOf(response.getStatusLine().getStatusCode()));
return true;
}
catch (Exception e) {
log.error("Failed to create bucket {}", bucketName, e);
return false;
}
}
@AfterSuite
public void stopServers() {
_couchbaseTestServer.stop();
}
/**
* Test that a single tuple document can be written successfully.
* @throws IOException
* @throws DataConversionException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testTupleDocumentWrite()
throws IOException, DataConversionException, ExecutionException, InterruptedException {
Properties props = new Properties();
props.setProperty(CouchbaseWriterConfigurationKeys.BUCKET, "default");
Config config = ConfigFactory.parseProperties(props);
CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
try {
Schema dataRecordSchema =
SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
.noDefault().endRecord();
Schema schema = SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
.type(dataRecordSchema).noDefault().endRecord();
GenericData.Record testRecord = new GenericData.Record(schema);
String testContent = "hello world";
GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
dataRecord.put("flags", 0);
testRecord.put("key", "hello");
testRecord.put("data", dataRecord);
Converter<Schema, String, GenericRecord, TupleDocument> recordConverter = new AvroToCouchbaseTupleConverter();
TupleDocument doc = recordConverter.convertRecord("", testRecord, null).iterator().next();
writer.write(doc, null).get();
TupleDocument returnDoc = writer.getBucket().get("hello", TupleDocument.class);
byte[] returnedBytes = new byte[returnDoc.content().value1().readableBytes()];
returnDoc.content().value1().readBytes(returnedBytes);
Assert.assertEquals(returnedBytes, testContent.getBytes(Charset.forName("UTF-8")));
int returnedFlags = returnDoc.content().value2();
Assert.assertEquals(returnedFlags, 0);
} finally {
writer.close();
}
}
/**
* Test that a single Json document can be written successfully
* @throws IOException
* @throws DataConversionException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test(groups={"timeout"})
public void testJsonDocumentWrite()
throws IOException, DataConversionException, ExecutionException, InterruptedException {
CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, ConfigFactory.empty());
try {
String key = "hello";
String testContent = "hello world";
HashMap<String, String> contentMap = new HashMap<>();
contentMap.put("value", testContent);
Gson gson = new Gson();
String jsonString = gson.toJson(contentMap);
RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonString);
writer.write(jsonDocument, null).get();
RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);
Map<String, String> returnedMap = gson.fromJson(returnDoc.content(), Map.class);
Assert.assertEquals(testContent, returnedMap.get("value"));
} finally {
writer.close();
}
}
private void drainQueue(BlockingQueue<Pair<AbstractDocument, Future>> queue, int threshold, long sleepTime,
TimeUnit sleepUnit, List<Pair<AbstractDocument, Future>> failedFutures) {
while (queue.remainingCapacity() < threshold) {
if (sleepTime > 0) {
Pair<AbstractDocument, Future> topElement = queue.peek();
if (topElement != null) {
try {
topElement.getSecond().get(sleepTime, sleepUnit);
} catch (Exception te) {
failedFutures.add(topElement);
}
queue.poll();
}
}
}
}
/**
* An iterator that applies the {@link AnyToCouchbaseJsonConverter} converter to Objects
*/
class JsonDocumentIterator implements Iterator<AbstractDocument> {
private final int _maxRecords;
private int _currRecord;
private Iterator<Object> _objectIterator;
private final Converter<String, String, Object, RawJsonDocument> _recordConverter =
new AnyToCouchbaseJsonConverter();
JsonDocumentIterator(Iterator<Object> genericRecordIterator) {
this(genericRecordIterator, -1);
}
JsonDocumentIterator(Iterator<Object> genericRecordIterator, int maxRecords) {
_objectIterator = genericRecordIterator;
_maxRecords = maxRecords;
_currRecord = 0;
}
@Override
public boolean hasNext() {
if (_maxRecords < 0) {
return _objectIterator.hasNext();
} else {
return _objectIterator.hasNext() && (_currRecord < _maxRecords);
}
}
@Override
public AbstractDocument next() {
_currRecord++;
Object record = _objectIterator.next();
try {
return _recordConverter.convertRecord("", record, null).iterator().next();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void remove() {
}
}
/**
* An iterator that applies the {@link AvroToCouchbaseTupleConverter} converter to GenericRecords
*/
class TupleDocumentIterator implements Iterator<AbstractDocument> {
private final int _maxRecords;
private int _currRecord;
private Iterator<GenericRecord> _genericRecordIterator;
private final Converter<Schema, String, GenericRecord, TupleDocument> _recordConverter =
new AvroToCouchbaseTupleConverter();
TupleDocumentIterator(Iterator<GenericRecord> genericRecordIterator) {
this(genericRecordIterator, -1);
}
TupleDocumentIterator(Iterator<GenericRecord> genericRecordIterator, int maxRecords) {
_genericRecordIterator = genericRecordIterator;
_maxRecords = maxRecords;
_currRecord = 0;
}
@Override
public boolean hasNext() {
if (_maxRecords < 0) {
return _genericRecordIterator.hasNext();
} else {
return _genericRecordIterator.hasNext() && (_currRecord < _maxRecords);
}
}
@Override
public TupleDocument next() {
_currRecord++;
GenericRecord record = _genericRecordIterator.next();
try {
return _recordConverter.convertRecord("", record, null).iterator().next();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void remove() {
}
}
class Verifier {
private final Map<String, byte[]> verificationCache = new HashMap<>(1000);
private Class recordClass;
void onWrite(AbstractDocument doc)
throws UnsupportedEncodingException {
recordClass = doc.getClass();
if (doc instanceof TupleDocument) {
ByteBuf outgoingBuf = (((TupleDocument) doc).content()).value1();
byte[] outgoingBytes = new byte[outgoingBuf.readableBytes()];
outgoingBuf.getBytes(0, outgoingBytes);
verificationCache.put(doc.id(), outgoingBytes);
} else if (doc instanceof RawJsonDocument) {
verificationCache.put(doc.id(), ((RawJsonDocument) doc).content().getBytes("UTF-8"));
} else {
throw new UnsupportedOperationException("Can only support TupleDocument or RawJsonDocument at this time");
}
}
void verify(Bucket bucket)
throws UnsupportedEncodingException {
// verify
System.out.println("Starting verification procedure");
for (Map.Entry<String, byte[]> cacheEntry : verificationCache.entrySet()) {
Object doc = bucket.get(cacheEntry.getKey(), recordClass);
if (doc instanceof TupleDocument) {
ByteBuf returnedBuf = (((TupleDocument) doc).content()).value1();
byte[] returnedBytes = new byte[returnedBuf.readableBytes()];
returnedBuf.getBytes(0, returnedBytes);
Assert.assertEquals(returnedBytes, cacheEntry.getValue(), "Returned content for TupleDoc should be equal");
} else if (doc instanceof RawJsonDocument) {
byte[] returnedBytes = ((RawJsonDocument) doc).content().getBytes("UTF-8");
Assert.assertEquals(returnedBytes, cacheEntry.getValue(), "Returned content for JsonDoc should be equal");
} else {
Assert.fail("Returned type was neither TupleDocument nor RawJsonDocument");
}
}
System.out.println("Verification success!");
}
}
/**
* Uses the {@link AsyncWriterManager} to write records through a couchbase writer
* It keeps a copy of the key, value combinations written and checks after all the writes have gone through.
* @param recordIterator
* @throws IOException
*/
private void writeRecordsWithAsyncWriter(Iterator<AbstractDocument> recordIterator)
throws IOException {
boolean verbose = false;
Properties props = new Properties();
props.setProperty(CouchbaseWriterConfigurationKeys.BUCKET, "default");
Config config = ConfigFactory.parseProperties(props);
CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
try {
AsyncWriterManager asyncWriterManager =
AsyncWriterManager.builder().asyncDataWriter(writer).maxOutstandingWrites(100000).retriesEnabled(true)
.numRetries(5).build();
if (verbose) {
// Create a reporter for metrics. This reporter will write metrics to STDOUT.
OutputStreamReporter.Factory.newBuilder().build(new Properties());
// Start all metric reporters.
RootMetricContext.get().startReporting();
}
Verifier verifier = new Verifier();
while (recordIterator.hasNext()) {
AbstractDocument doc = recordIterator.next();
verifier.onWrite(doc);
asyncWriterManager.write(doc);
}
asyncWriterManager.commit();
verifier.verify(writer.getBucket());
} finally {
writer.close();
}
}
private List<Pair<AbstractDocument, Future>> writeRecords(Iterator<AbstractDocument> recordIterator,
CouchbaseWriter writer, int outstandingRequests, long kvTimeout, TimeUnit kvTimeoutUnit)
throws DataConversionException, UnsupportedEncodingException {
final BlockingQueue<Pair<AbstractDocument, Future>> outstandingCallQueue =
new LinkedBlockingDeque<>(outstandingRequests);
final List<Pair<AbstractDocument, Future>> failedFutures = new ArrayList<>(outstandingRequests);
int index = 0;
long runTime = 0;
final AtomicInteger callbackSuccesses = new AtomicInteger(0);
final AtomicInteger callbackFailures = new AtomicInteger(0);
final ConcurrentLinkedDeque<Throwable> callbackExceptions = new ConcurrentLinkedDeque<>();
Verifier verifier = new Verifier();
while (recordIterator.hasNext()) {
AbstractDocument doc = recordIterator.next();
index++;
verifier.onWrite(doc);
final long startTime = System.nanoTime();
Future callFuture = writer.write(doc, new WriteCallback<TupleDocument>() {
@Override
public void onSuccess(WriteResponse<TupleDocument> writeResponse) {
callbackSuccesses.incrementAndGet();
}
@Override
public void onFailure(Throwable throwable) {
callbackFailures.incrementAndGet();
callbackExceptions.add(throwable);
}
});
drainQueue(outstandingCallQueue, 1, kvTimeout, kvTimeoutUnit, failedFutures);
outstandingCallQueue.add(new Pair<>(doc, callFuture));
runTime += System.nanoTime() - startTime;
}
int failedWrites = 0;
long responseStartTime = System.nanoTime();
drainQueue(outstandingCallQueue, outstandingRequests, kvTimeout, kvTimeoutUnit, failedFutures);
runTime += System.nanoTime() - responseStartTime;
for (Throwable failure : callbackExceptions) {
System.out.println(failure.getClass() + " : " + failure.getMessage());
}
failedWrites += failedFutures.size();
System.out.println(
"Total time to send " + index + " records = " + runTime / 1000000.0 + "ms, " + "Failed writes = " + failedWrites
+ " Callback Successes = " + callbackSuccesses.get() + "Callback Failures = " + callbackFailures.get());
verifier.verify(writer.getBucket());
return failedFutures;
}
@Test
public void testMultiTupleDocumentWrite()
throws IOException, DataConversionException, ExecutionException, InterruptedException {
CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, ConfigFactory.empty());
try {
final Schema dataRecordSchema =
SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
.noDefault().endRecord();
final Schema schema =
SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
.type(dataRecordSchema).noDefault().endRecord();
final int numRecords = 1000;
int outstandingRequests = 99;
Iterator<GenericRecord> recordIterator = new Iterator<GenericRecord>() {
private int currentIndex;
@Override
public void remove() {
}
@Override
public boolean hasNext() {
return (currentIndex < numRecords);
}
@Override
public GenericRecord next() {
GenericData.Record testRecord = new GenericData.Record(schema);
String testContent = "hello world" + currentIndex;
GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
dataRecord.put("flags", 0);
testRecord.put("key", "hello" + currentIndex);
testRecord.put("data", dataRecord);
currentIndex++;
return testRecord;
}
};
long kvTimeout = 10000;
TimeUnit kvTimeoutUnit = TimeUnit.MILLISECONDS;
writeRecords(new TupleDocumentIterator(recordIterator), writer, outstandingRequests, kvTimeout, kvTimeoutUnit);
} finally {
writer.close();
}
}
@Test
public void testMultiJsonDocumentWriteWithAsyncWriter()
throws IOException, DataConversionException, ExecutionException, InterruptedException {
final int numRecords = 1000;
Iterator<Object> recordIterator = new Iterator<Object>() {
private int currentIndex;
@Override
public boolean hasNext() {
return (currentIndex < numRecords);
}
@Override
public Object next() {
String testContent = "hello world" + currentIndex;
String key = "hello" + currentIndex;
HashMap<String, String> contentMap = new HashMap<>();
contentMap.put("key", key);
contentMap.put("value", testContent);
currentIndex++;
return contentMap;
}
@Override
public void remove() {
}
};
writeRecordsWithAsyncWriter(new JsonDocumentIterator(recordIterator));
}
@Test
public void testMultiTupleDocumentWriteWithAsyncWriter()
throws IOException, DataConversionException, ExecutionException, InterruptedException {
final Schema dataRecordSchema =
SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
.noDefault().endRecord();
final Schema schema =
SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
.type(dataRecordSchema).noDefault().endRecord();
final int numRecords = 1000;
Iterator<GenericRecord> recordIterator = new Iterator<GenericRecord>() {
private int currentIndex;
@Override
public void remove() {
}
@Override
public boolean hasNext() {
return (currentIndex < numRecords);
}
@Override
public GenericRecord next() {
GenericData.Record testRecord = new GenericData.Record(schema);
String testContent = "hello world" + currentIndex;
GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
dataRecord.put("flags", 0);
testRecord.put("key", "hello" + currentIndex);
testRecord.put("data", dataRecord);
currentIndex++;
return testRecord;
}
};
writeRecordsWithAsyncWriter(new TupleDocumentIterator(recordIterator));
}
}