Giving Neo4j 2.2 a Workout

rhino_running

Neo4j 2.2 is getting released any day now, so let’s put the Release Candidate through its paces with Gatling. Once we download and start it up, you’ll notice it wants us to authenticate.

authenticate

The default username and password is neo4j/neo4j. We’ll use that and it will ask us to change it to something more secure. So give me a minute or two here to come up with something original.

SwordfishBRCLTv1

Ok I got it. Now that we’ve connected lets create a dataset. We’re going to create a random social network like we have in the past. So first we’ll create the users:

WITH ["Jennifer","Michelle","Tanya","Julie","Christie","Sophie","Amanda","Khloe","Sarah","Kaylee"] AS names 
FOREACH (r IN range(0,100000) | CREATE (:User {username:names[r % size(names)]+r}))

and connect them:

MATCH (u1:User),(u2:User)
WITH u1,u2
LIMIT 5000000
WHERE rand() < 0.1
CREATE (u1)-[:FRIENDS]->(u2);

Let’s try running a query. Which users are friends with “Kaylee83639”?

MATCH (me:User {username:'Kaylee83639'})-[:FRIENDS]-(people)
RETURN people.username

Screen Shot 2015-03-06 at 4.08.21 PM

Great, that works. Now lets put the browser down and fire up IntelliJ. We’ll create a new project from a Maven Archetype. We are going to be using Gatling to test our cypher query, but we’ll do it directly in our IDE instead of on the console.

Screen Shot 2015-03-06 at 3.17.00 PM

We are using “io.gatling.highcharts” for the GroupId, “gatling-highcharts-maven-archetype” for the ArtifactId and “2.1.2” for the version. Give it a few more settings, then wait a few seconds while it creates your project. Next we’ll create a simulation, lets call it “GetFriends” and set it up to connect to our localhost on port 7474.

import io.gatling.core.Predef._
import io.gatling.core.scenario.Simulation
import io.gatling.http.Predef._

import scala.concurrent.duration._

class GetFriends extends Simulation {

  val httpConf = http
    .baseURL("http://localhost:7474")
    .acceptHeader("application/json")
    /* Uncomment to see the response of each request.
    .extraInfoExtractor(extraInfo => {
      println(extraInfo.response.body.string)
      Nil
    }).disableResponseChunksDiscarding
   */

For debugging purposes, we can print the response of each request to the screen, but I normally leave this off unless something doesn’t make sense. Next we’ll add our cypher query and format it in a way that the Transactional Cypher HTTP endpoint expects.

val query = """MATCH (me:User {username:'Kaylee83639'})-[:FRIENDS]-(people) RETURN people.username"""
val cypherQuery = """{"statements" : [{"statement" : "%s"}]}""".format(query)

Now we can setup our scenario, to run the query 1000 times for each gatling user. We’ll pass our cypher query as the body of a JSON post message to the cypher endpoint and make sure we get an “OK” response from the server.

  val scn = scenario("Get Friends")
    .repeat(1000) {
    exec(
      http("get friends")
        .post("/db/data/transaction/commit")
        .body(StringBody(cypherQuery))
        .asJSON
        .check(status.is(200))
    )
  }

Next, we’ll setup our scenario to start with 1 user and ramp up to 10 users over a period of 10 seconds.

  setUp(
    scn.inject(rampUsers(10) over(10 seconds)).protocols(httpConf)
  )

… and now we can run it. Right click on the “Engine” file in our scala directory and run it.

Screen Shot 2015-03-06 at 3.39.07 PM

Follow along with the instructions (or just hit enter twice) and lets see what happens:

================================================================================
---- Global Information --------------------------------------------------------
> request count                                      10000 (OK=0      KO=10000 )
> min response time                                      0 (OK=-      KO=0     )
> max response time                                      4 (OK=-      KO=4     )
> mean response time                                     0 (OK=-      KO=0     )
> std deviation                                          0 (OK=-      KO=0     )
> response time 50th percentile                          0 (OK=-      KO=0     )
> response time 75th percentile                          1 (OK=-      KO=1     )
> mean requests/sec                                1047.559 (OK=-      KO=1047.559)
---- Response Time Distribution ------------------------------------------------
> t < 800 ms                                             0 (  0%)
> 800 ms < t < 1200 ms                                   0 (  0%)
> t > 1200 ms                                            0 (  0%)
> failed                                             10000 (100%)
---- Errors --------------------------------------------------------------------
> status.find.is(200), but actually found 401                     10000 (100.0%)
================================================================================

Whoa! They all failed. But we can easily tell why. It was expecting a status of 200 and got a 401 instead. If you remember your http status codes, 401 means “Unauthorized”. We need to change our test to pass in the username and password we created earlier.

val scn = scenario("Get Friends")
    .repeat(1000) {
    exec(
      http("get friends")
        .post("/db/data/transaction/commit")
        .basicAuth("neo4j", "swordfish")
        .body(StringBody(cypherQuery))
        .asJSON
        .check(status.is(200))
    )
  }

Now it works. I’ll run the test a couple of times to get everything nice and warmed up and we get about 32 requests per second.

Screen Shot 2015-03-06 at 4.22.08 PM

That doesn’t look right, let’s see what’s going on. Back to Neo4j, we’ll add the word “PROFILE” to the beginning of our query…

PROFILE MATCH (n:User {username:'Kaylee83639'})-[:FRIENDS]-(people) RETURN people.username

…and Neo4j will give us a visualization of just what is going on.

Screen Shot 2015-03-06 at 4.18.56 PM

Take a look at that. It’s scanning 100,000 User nodes looking for a username property that equals “Kaylee83639”. It’s doing that because we created our dataset but forgot to add indexes! So let’s do that now:

CREATE INDEX ON :User(username)

Wait, wait, let’s not do that. In this case, the username property is meant to be unique across all our User nodes. Let’s create a Uniqueness Constraint instead which will make sure only one Kaylee83639 exists in our graph and also create an index for us.

CREATE CONSTRAINT ON (me:User) ASSERT me.username IS UNIQUE

We can verify this by running :schema in our web console.

Screen Shot 2015-03-06 at 8.32.22 PM

Lets try our profile again.

Screen Shot 2015-03-06 at 8.33.31 PM

That looks better. Now it’s using the User(username) index to find “Kaylee83639” instead of scanning the username properties of all the User Nodes. Yes, I said ALL the User Nodes because it didn’t know that there is only one Kaylee83639. So back to Gatling, what happens if we try our test again?

Screen Shot 2015-03-06 at 4.25.29 PM

About 1035 requests per second. That’s a 1000 requests more per second than before and a massive improvement. So please, if you are starting out with Neo4j, don’t forget to add Uniqueness Constraints or Schema Indexes to any properties that will be used as a starting point to your traversals.

Now we know how fast we can get Kaylee83639’s friends, but what the other users in our database? Let’s modify our performance test to query different people. First we’ll need a sample of say 1000 users.

MATCH (n:User) RETURN n.username AS username ORDER BY rand() limit 1000

Let’s export and save that as a CSV file.

Screen Shot 2015-03-06 at 7.47.47 PM

We’ll put that file in our project under the src/test/resources/data directory as usernames.csv and change our code to reference it.

val feeder = csv("usernames.csv").circular

Then we’ll change our cypher query to make use of the parameter.

val query = """MATCH (me:User {username:{username}})-[:FRIENDS]-(people) RETURN people.username"""
val cypherQuery = """{"statements" : [{"statement" : "%s", "parameters" : { "username": "${username}" }}]}""".format(query)

You may have to scroll the code above to the right, but you’ll notice this little nugget:

"${username}"

This is Gatling magic (technically it’s a session attribute being modified by Gatling’s Expression Language which automagically parses it and dynamically changes its value). It will replace that ${username} with a username value from the usernames.csv file if we feed it that value when we execute our tests. So we will change the scenario to use the feed:

  val scn = scenario("Get Friends")
    .repeat(1000) {
    feed(feeder)
    .exec(
      http("get friends")
      ...

Finally we’ll run the test again.

Screen Shot 2015-03-06 at 7.59.43 PM

No surprise here, it takes about the same time as getting just one user because the operation is the same, find a node in the User(username) schema index and traverse from there. Whether you have 100K users, 10M users of 1B users it should still take approximately the same time to find their friends.

So make use of the new PROFILE capabilities of the Neo4j Browser to get a better idea of what that cypher query is doing. Look for NodeByLabelScans that can be replaced with NodeUniqueIndexSeeks or NodeIndexSeeks by adding unique constraints or indexes where you need them. Lastly be sure you always test everything before heading in to production. If you’ve been following my blog, you know there are always ways to make Neo4j go faster. If you aren’t seeing the performance you need, make sure you reach out and let me know.

As always, the code is on github.

Tagged , , , , , , , ,

Leave a comment