jeudi 21 avril 2016

N1QL query optimisations

N1QL, our new language SQL for JSON document, is attracting a lot of interest these days. Due to the simplicity of the N1QL syntax, it is easy to write rather complex queries really fast. What if performances don't follow the expectations ?  I would like to use this blog to share with you the feedback from a (french) customer and to show you how N1QL queries have been optimised to get the best response time.

This story starts with a Product Catalog database, stored in a bucket called MyBucket. MyBucket stores 18000 documents for a total storage of 39MB. The bucket is small but since data have been completely normalised, queries use joins a lot. In MyBucket, documents can be produit, categorie, or have other document types:
- produit is a commercial product
- categorie is a commercial category

A product is designed as depicted in the following picture :


A product is embedding an array called categories. This array categories is a list of anonymous documents, each having a single field called identifiantCategorie. A field identifiantCategorie stores a foreign key to a category document, designed as follow :



The original query is doing a join between a product and a given category, to find all products having a given category in its array categories. For instance, here is the query to find all products referring to category_1 :


With Couchbase 4.1, the query response time is 2.63 secondes, which is rather slow. What can we do to improve this ?

Step 0: explain the query

First, let's EXPLAIN the query :



As we can see, this query is doing a full scan using the primary index on this bucket. This means that for each document in MyBucket, Couchbase needs to retrieve each document in order to test for the presence of an array called categories, and perform the join. Depending on the size of the bucket, the response time can grow.

Step 1: modify the document design


Let's add a property type in each document that will reflect the document type and define virtual collections in the same bucket:
- product document : adding "type":"produitCommercial"
- category document : adding "type":"categorie"

We now update the query to filter documents based on their type.


Then we create an index on this new field to accelerate the fetch of documents.


Don't forget to check that the new index will be used by the query:


Finally test the query :


The query execution time is now 356 ms, so more than 7 times faster than before ! This improvement is due to the selectivity of this new field. A quick count on MyBucket to find all documents where type="produitCommercial" shows that there are 2005 products, so a selectivity of (2005/18000)=11%.

Since there are several document types in the same bucket, create a index on this field will split the bucket into different virtual collections and speed up the fetching of each collection. Of course with a single document type in the bucket, these kind of optimisation will never improve response time since all documents will have the same type value (and a selectivity of 100%).

Step 2 : Memory-Optimized Global Indexing with Couchbase 4.5

Now let's backup and restore the data from this Couchbase 4.1 node to a Couchbase 4.5 node. Couchbase 4.5 is actually in Developper Preview and will be released soon. Couchbase 4.5 will provide a bunch of new features and faster indexing. To make the best of new Couchbase 4.5 indexing, Memory-Optimised Global Indexing has been activated in this node.


Memory-optimized global indexes enable in-memory index processing and index scans with the lowest latency. These indexes will be stored in RAM only, rather than part in memory, part in disk as they are in Couchbase 4.1.

With exactly the same configuration as before, the query is already much faster when running with Memory-Optimised global index, now executed in 222ms instead of 356ms :




Step 3 : Array indexing

Looking deeper in the execution plan, we notice that the join operation is looping on every identifiantCategorie field of each document inside the categories array of a product.



What is we could index this identifiantCategorie field ? Since Couchbase 4.5, array indexing has been added. This means that the content of an array can be indexed, not only the whole array itself (already available in Couchbase 4.1). Let's create an index on the identifiantCategorie field of each document inside the categories array of a product.


Is this index used by the query ? In a matter of fact no. The query need to be customised in order to make it use the new index :




Surprisingly, the new response time is worse than before : 293 ms instead of 222 ms. What happened ? Both indexes are queried and intersected. The use of both indexes make the query slower. Fortunately, we can force N1QL to use the index we want, with the USE INDEX clause.


The query is now executed in 24.74 ms ! Much better. In fact, more than 100 times faster than the initial query.

Step 4 : Covering index


Explaining the query shows that data are fetched from the data service.




To eliminate this step, we create a covering index : when an index includes all the actual values of all the fields specified in the query, the index does not require an additional step to fetch the values from the data service.


We want N1QL to use this new index and we check how this index is explained:


There is no more fetch step in the query execution plan because every information needed by the query is already stored within the index, and in memory thanks to Memory Optimised Global Index.

The new query response time is now less than 6ms, more than 430 times faster than the initial query !



Conclusion

We've seen a number of ways to improve a N1QL query response time:

  • Use a specific type field to create virtual collections and create an index on this field. The more selective is the field the more efficient will be the index.
  • Use Memory-Optimised Global Index, available with Couchbase 4.5
  • Add array indexing if your query references to a field inside an embedded array
  • USE INDEX can be used to force the use of a preferred index inside a N1QL query
  • To get the best response time, try and create a covering index to avoid the fetch step
  • EXPLAIN, EXPLAIN and EXPLAIN again !

lundi 1 février 2016

Couchbase Full Text (CBFT) for Content Management

Full Text Search (FTS) is a main capability of Content Management systems to search both content and metadata associated to the content. In a previous blog, I already discussed about a new fully scalable architecture for Content Management using Apache Chemistry with Couchbase repository for metadata (and possibly blobs). Today, I would like to discuss about how to integrate FTS capability in a scalable way in this architecture without the need for yet another tier (ElasticSearch, Solr, LudicWorks). 

In 2015, Couchbase has announced the development of CBFT which stands for Couchbase Full Text search, actually in developer preview. CBFT is simple, integrated distributed Full Text server which covers 80% of features of most applications.You can find more informations on CBFT here: http://connect15.couchbase.com/agenda/sneak-peek-cbft-full-text-search-couchbase/

In this article, I will start to investigate how to integrate CBFT in CMIS Apache Chemistry for metadata full text search.

  • Setup
To install Couchbase, follow the documentation here.
Create a bucket called cmismeta. This bucket contains the metatada of each content (folder, file).
To install Apache Chemistry using Couchbase repository, follow the documentation here.
To install CBFT, follow the documentation here.

  • Create a CBFT index
Start CBFT on a local node : cbft -s http://localhost:8091
Point your web browser to cbft's web admin UI : http://localhost:8095



On the Indexes listing page, click on the New Index  button.
Create an index called cmis-fts on bucket cmismeta.

  • Test your index
To test your index, you need to add content on cmismeta bucket. You can either do it using the Apache Chemistry workbench to create content (folder, files) that will be associated with metadata in cmismeta bucket, or by adding simple content for testing (then remove it).

In this example, I already have a bunch of files added to the Content Management Couchbase repository.

Open the query tab and enter a query using Bleve syntax


  • CMIS Apache Chemistry project


 First, you need to activate the full text query capabilities of CMIS Couchbase repository class.


public class CouchbaseRepository {
   private RepositoryInfo createRepositoryInfo(CmisVersion cmisVersion) {
        // set repo infos
        RepositoryInfoImpl repositoryInfo = new RepositoryInfoImpl();
        repositoryInfo.setCmisVersionSupported(cmisVersion.value());
        ...
        // set repo capabilities
      RepositoryCapabilitiesImpl capabilities = new RepositoryCapabilitiesImpl();
        capabilities.setCapabilityQuery(CapabilityQuery.FULLTEXTONLY);
        ...
        repositoryInfo.setCapabilities(capabilities);
        return repositoryInfo;
     }
}

To query the CBFT index, we are using the REST API with a Jersey client.

First, add the dependency in the maven pom file.

        <dependency>
           <groupId>com.sun.jersey</groupId>
<artifactId>jersey-client</artifactId>  
          <version>1.8</version>
</dependency>

Then create a new CBFT service class. This service needs the CBFT location and index name. I provides a simple query method returning a list of keys referring to cmismeta bucket in Couchbase.

package org.apache.chemistry.opencmis.couchbase;

import java.util.ArrayList;
import java.util.List;

import com.couchbase.client.java.document.json.JsonArray;
import com.couchbase.client.java.document.json.JsonObject;
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.WebResource;


public class CBFTService  {

 private String cbftLocation = null;  
private Client client = null;
private String indexid = null;

public CBFTService(String location, String indexid) {
this.cbftLocation = location;
this.indexid = indexid;
client = Client.create();
}

/** Search cbft index.
* @param query the query to search
* @return list of keys matching the query
* */
public List<String> query(String query){
List<String>
results = new ArrayList<String>();

WebResource
webResource = client
.resource(
"http://"+this.cbftLocation+":8095/api/index/"+indexid+"/query");

  String input = "{" +
     "\"q\": \""+query+"\"," +
"\"indexName\": \""+indexid+"\"," +
"\"size\": 10,"+
"\"from\": 0,"+
"\"explain\": true,"+
"\"highlight\": {}," +
"\"query\": {" +
"\"boost\": 1,"+
"\"query\": \""+query + "\""+
"},"+
"\"fields\": [" +
"\"*\"" +
"]," +
"\"ctl\": {" +
"\"consistency\": {"+
"\"level\": \"\"," +
"\"vectors\": {}"+
"},"+
"\"timeout\": 0"+
"}"+
"}";
    ClientResponse response = webResource.type("application/json")
.post(ClientResponse.
class, input);

if (response.getStatus() != 200) {
throw new RuntimeException("Failed : HTTP error code : "
+
response.getStatus());
}

  String output = response.getEntity(String.class);

JsonObject
content = JsonObject.fromJson(output);

   JsonArray hits = content.getArray("hits");

if(hits != null){
  String id;
     for(int i=0 ; i<hits.size(); i++){
id = hits.getObject(i).getString("id");
  results.add(id);
    }  
}

return results;

}

}

You can now query the Content Management server using the workbench to retrieve content using the CBFT capability and click on the result to see the associated content.


lundi 11 janvier 2016

Storing blobs in Couchbase for Content Management

On my previous post, I talked about how to setup a flexible content management service using Couchbase as the metadata repository, on top of an Apache Chemistry server. The blobs themselves (pdf, pptx, docx, etc) are stored in a separate file system or in a blob store.
Today, I would like to show how Couchbase can be used to store the blobs themselves, using a custom chunk manager. The idea is to store not only the metadata of a document (date of creation, creator, name, etc.) but in addition the blob itself.

The purpose of this new architecture is to reduce the number of different systems (and licences to pay) and also to benefit directly from the replication features offered by Couchbase.

First, let’s remember that Couchbase is not a blob store. This a memory-based document store, with an adhoc cache management tuned so that most of the data stored in Couchbase should be in RAM for fast querying. Data are also replicated between nodes (if replication is enabled) inside the cluster and optionnaly outside the cluster if XDCR is used. This is why data stored in Couchbase can not be larger than 20 MB. This is a hard limit, and in real life 1MB is already a large document to store. 

Knowing that, the point is  : how can I store large binary data in Couchbase ? 
Simple answer : chunk it !

The new architecture looks now like this.



There is now 2 buckets in Couchbase :
  1. cmismeta : used to store metadata
  2. cmisstore : used to store blobs

When a folder is created, only the bucket cmismeta is modified with a new entry because of course, a folder is not associated to any blob. This is simply a structure used by the user to organise the documents and navigate in the folder tree. Folders are virtuals. The entry point of the structure is the root folder as described previously.

When a document (for instance a pdf or a pptx) is inserted into a folder, 3 things happen:
  • A json document containing all its metadata is inserted into the cmismeta bucket, with a unique key. Let’s say for instance that the document has the key L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=.
  • A new json document with the same key is created in the cmisstore bucket. This document contains the number of chunk, the max size of each chunk (same for all chunk except for the last one that might be smaller) and the application mime type.
  • The blob attached to the document is chunked into binary pieces (the size depends on a parameter you can set in the properties of the project). By default, a chunk is 500KB large. Each chunk is stored in the cmisstore bucket as a binary document, with the same key “L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=” as prefix, and a suffix "::partxxx” where xxx is the number of the chunk (0, 1, 2, …).

For instance, if a insert a pptx called CouchbaseOverview.pptx which size is 4476932 bytes into Couchbase, I get :
  • In bucket cmismeta, a json document called L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=

  • In bucket cmisstore, a json document also called L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=

  • 9 chunks containing binary data and called L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=::part0, L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=::part1, … , L0NvdWNoYmFzZU92ZXJ2aWV3LnBwdHg=::part8

The CouchbaseStorageService is the class implementing the StorageService interface already used for local storage or S3 storage as I showed into my previous blog. The first difference is the reuse of the same CouchbaseCluster instance as the one used for the MetadataService because only one Couchbase Environnement should be instantiated to save lots of resources (RAM, CPU, Network, etc).

Now let’s see the writeContent method itself :

 /**
* ContentStream is split into parts
*/
public void writeContent(String dataId, ContentStream contentStream)
throws StorageException {
        // count the number of parts
        long length = contentStream.getLength();  
        long nbparts = length / BUFFER_SIZE;
        // the last part  
        if (length - nbparts * BUFFER_SIZE > 0)  nbparts++;  
        JsonObject doc = JsonObject.empty();
        doc.put("count", nbparts);
        doc.put("mimetype", contentStream.getMimeType());
        doc.put("length", length);
        long totalLength = 0;
        int read = 0; // The number of bytes not yet read
        byte[] byteArray = new byte[BUFFER_SIZE];
        int offset = 0;
        for (int i = 0; i < nbparts; i++) {
            try {
                read = contentStream.getStream()
                       .read(byteArray, 0, BUFFER_SIZE);    
                totalLength += read;
                             offset += read;
                writeContentPart(dataId + PART_SUFFIX + i, byteArray, read);
                doc.put(dataId + PART_SUFFIX + i, read);
            } catch (IOException e) {
                 e.printStackTrace();  
            }
        }

        if (totalLength != length)
                    throw new StorageException("Wrong number of bytes");    
        
         JsonDocument jsondoc = JsonDocument.create(dataId, doc);
         bucket.upsert(jsondoc);
     }

     private void writeContentPart(String partId, byte[] bytesArray, int length)
          throws StorageException {
                   BinaryDocument bDoc = BinaryDocument.create(partId,
          Unpooled.copiedBuffer(bytesArray));
                  bucket.upsert(bDoc);
     } 

Now what to do to retrieve the file from Couchbase ? The main idea is to get each part, concatenate each other is the same order they were cut and send the byte array to the stream. There is probably a lot of way to do this, I simply implement a straightforward one using a single byte array where I write each byte into.

private InputStream getInputStream(String dataId, StringBuffer mimeType)
throws StorageException {
JsonDocument
doc = bucket.get(dataId);
JsonObject
json = doc.content();
Integer
nbparts = json.getInt("count");
Integer
length = json.getInt("length");

          if(nbparts==null || length==null || mimeType==null
                 throw new StorageException("Document invalid");
          mimeType.append(json.getString("mimetype"));
          byte[] byteArray = new byte[length];
          // for each part, read the content into the byteArray
          int offset = 0;
          Integer partLength = null;
         
          for (int i = 0; i < nbparts; i++) {  
               partLength = json.getInt(dataId + PART_SUFFIX + i);
               if(partLength == null
                     throw new StorageException("length of part "+i+" is mandatory");
               BinaryDocument bDoc
                    bucket.get(dataId + PART_SUFFIX + i,BinaryDocument.class);
               ByteBuf part = bDoc.content();
               byte[] dst = new byte[partLength];
               part.readBytes(dst);
               for (int k = 0; k < partLength; k++) {
                    byteArray[k + offset] = dst[k];
               }
               offset += partLength;
               part.release();
          }
          InputStream stream = new ByteArrayInputStream(byteArray);  
          return stream;
}

Finally let’s see what happens in the workbench tool provided by Apache Chemistry ? I can see the document in the root folder and if I double click on it, the content is streamed from Couchbase and displayed in the associated viewer (here powerpoint) based on the mime type.

Workbench and document opened in powerpoint after double clic
Where can I find the code ?

The code implementing these CMIS server on top of Couchase is available at Github here :

https://github.com/cecilelepape/cmis-couchbaseonly


Specials Thanks to Laurent Doguin.