JanusGraphでインデックスを使う

JanusGraphでのインデックス(Composite Index)の張り方を説明する。
今回はインデックス・バックエンドが必要なMixed Indexについては触れない。

英語読めるなら公式のドキュメント読めばいいのだけれども。
[Indexing for Better Performance](https://docs.janusgraph.org/index-management/index-performance/)


## インデックスがない場合のクエリ応答時間

お試し用のデータとして、以下のようなグラフを作成する。グラフというよりはただのテーブルである。頂点数は1000,10000,100000の3パターンを試した。

|ID|(Property)name|(Property)age|
|---|---|---|
|4235272|A0000003|3|
|4239368|A0000004|43|
|4243464|A0000011|94|
|4247560|A0000024|77|
|4251656|A0000025|22|
|...|||

nameはA0000001から連番で設定される。ageは0～99までランダムに与えられるようにした。なお、10万頂点の場合、登録に10分くらいかかる。

以下の、特定の頂点を1個だけ取り出す簡単なクエリを発行する。

```python
g.V().has("name", "A0000001").valueMap()
```

<details><summary>プログラム</summary><div>

```python

from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection
from gremlin_python.process.anonymous_traversal import traversal
from gremlin_python.process.graph_traversal import __
from gremlin_python.process.traversal import T, P, TextP
import random
import timeit
import sys


domain = "localhost"  # GremlinサーバーのIPアドレスorドメイン名
port = 8182  # Gremlinサーバーのポート番号(デフォルトでは8182)
traversal_name = "g"  # TraversalGraphSourceの変数名(デフォルトでg)
url = f"ws://{domain}:{port}/gremlin"  # 接続するURL


class GraphConnection:
    def __init__(self):
        self.connection = DriverRemoteConnection(url, traversal_name)
        self.g = traversal().withRemote(self.connection)
        self.limit = 100000

    def __del__(self):
        self.close()

    def close(self):
        self.connection.close()
        self.connection = None
        self.g = None

    def drop(self):
        # グラフの頂点をドロップする
        # 頂点数が多くなると g.V().drop()で時間とメモリの消費が厳しすぎるので
        # limitを使って少しずつ削除する
        # 頂点数を得る(g.V().count())のも高コストなので、頂点数の上限はself.limitとする
        per = 100
        loop = self.limit // per
        for _ in range(loop):
            self.g.V().limit(per).drop().iterate()

    def makeData(self, num):
        if num > self.limit:
            raise Exception(f"[ERROR]upper limit is {self.limit}.")
        if not self.connection:
            raise Exception("Connection is not established.")

        self.drop()
        for i in range(num):
            if i % 10 == 0:
                print(i)
            name = f"A{i:07}"
            age = random.randint(0, 100)
            self.g.addV("person").property("name", name).property("age", age).iterate()

    def showVertices(self, num=None):
        if not self.connection:
            raise Exception("Connection is not established.")

        vertices = self.g.V().valueMap(True)
        n = 0
        while vertices.hasNext():
            vertex = vertices.next()
            print(vertex)
            n += 1
            if num and n >= num:
                break

    def getVertex(self, name):
        if not self.connection:
            raise Exception("Connection is not established.")

        vertex = self.g.V().has("name", name).valueMap("name", "age").next()
        return vertex


def new_vertices(num):
    graph = GraphConnection()
    graph.makeData(num)


def show(num):
    graph = GraphConnection()
    graph.showVertices(num)


def measure(num):
    graph = GraphConnection()
    total = timeit.timeit(lambda: graph.getVertex("A0000001"), number=num)
    average = total / num
    print(f"total={total:.6}, average={average:.6}")


def main():
    if len(sys.argv) < 3:
        print(f"usage:")
        print(f"  {sys.argv[0]} -new num   : initialize graph vertices")
        print(f"  {sys.argv[0]} -p num     : show vertices")
        print(f"  {sys.argv[0]} -q num     : query time measurement")
    elif sys.argv[1] == "-new":
        num = int(sys.argv[2])
        new_vertices(num)
    elif sys.argv[1] == "-p":
        num = int(sys.argv[2])
        show(num)
    elif sys.argv[1] == "-q":
        num = int(sys.argv[2])
        measure(num)


if __name__ == '__main__':
    main()

```

</div></details>

それぞれのグラフで100回ずつ測定しての平均値は以下のようになった

|頂点数|クエリ1回の平均応答時間[ミリ秒]|
|---|---|
|1000|15|
|10000|130|
|100000|1605|

あからさまに遅い。インデックスなしで稼働させるのは無理がある気がする。

## 実験用サーバーの立ち上げ方/リセット方法

いろいろ試すためにDocker Composeを使ってJanusGraphサーバーを立ち上げられるようにする。適当なディレクトリに`docker-compose.yml`ファイルを作成し、以下のように記述する。

```yml:docker-compose.yml
version: '3'
services:
    indexjanus:
        image: janusgraph/janusgraph
        ports:
            - 8182:8182
        volumes:
            - janusgraph-index-test:/var/lib/janusgraph
volumes:
    janusgraph-index-test:

```

立ち上げる場合は

```sh
$ docker-compose up -d
```

停止する場合は

```sh
$ docker-compose down
```

データを抹消したい(リセットしたい)場合は

```sh
$ docker-compose down -v
```




## インデックスを張る

サーバーを立ち上げたら、先ほどのプログラムで1頂点以上を突っ込んでおく。グラフが空だと、スキーマを自分で1から作らなければならず、面倒くさいため(本当は最初にスキーマを作るべきだが)。Gremlinコンソールから操作して、作業を開始する。

まず、接続する。

```groovy
gremlin> :remote connect tinkerpop.server conf/remote.yaml session
==>Configured localhost/127.0.0.1:8182-[347fb18b-1f41-4006-93fb-f0c67d26e508]
```

次に、現在のスキーマを確認する

```groovy
gremlin> :> m = graph.openManagement()  // 設定用のインターフェースを取得
==>org.janusgraph.graphdb.database.management.ManagementSystem@668c7485
gremlin> :> m.printSchema()
==>------------------------------------------------------------------------------------------------
Vertex Label Name              | Partitioned | Static                                             |
---------------------------------------------------------------------------------------------------
person                         | false       | false                                              |
---------------------------------------------------------------------------------------------------
Edge Label Name                | Directed    | Unidirected | Multiplicity                         |
---------------------------------------------------------------------------------------------------
---------------------------------------------------------------------------------------------------
Property Key Name              | Cardinality | Data Type                                          |
---------------------------------------------------------------------------------------------------
name                           | SINGLE      | class java.lang.String                             |
age                            | SINGLE      | class java.lang.Integer                            |
---------------------------------------------------------------------------------------------------
Vertex Index Name              | Type        | Unique    | Backing        | Key:           Status |
---------------------------------------------------------------------------------------------------
---------------------------------------------------------------------------------------------------
Edge Index (VCI) Name          | Type        | Unique    | Backing        | Key:           Status |
---------------------------------------------------------------------------------------------------
---------------------------------------------------------------------------------------------------
Relation Index                 | Type        | Direction | Sort Key       | Order    |     Status |
---------------------------------------------------------------------------------------------------
```

`Property Key Name`に`name`と`age`が登録されていることを確認する。また、`Vertex Index Name`の欄が空であることを確認する。現状インデックスは何も作成されていない。

次に、nameプロパティについてインデックスを作成する。

```groovy
gremlin> :> name = m.getPropertyKey("name")
==>name
gremlin> :> m.buildIndex('by_name', Vertex.class).addKey(name).buildCompositeIndex()
==>by_name
gremlin> :> m.commit()
==>null
```

インデックス名はここでは`by_name`とした。別にどんな名前でもいいが、プロパティとまったく同じ名前は推奨しない(混乱するので)。

ここで暫く待つ。作成された(`INSTALLED`)インデックスが登録された状態(`REGISTERED`)になるまで。この時間が結構バラツキがあり、法則性も良くわからない。

インデックスの状態が変化するまで待機する関数`awaitGraphIndexStatus`を使うのが手っ取り早い。ただし、Gremlinコンソールの標準設定により、30秒応答がないとエラーを吐いてしまうので、`:remote config timeout`コマンドで予め時間を延ばしておかないといけない(単位はミリ秒で指定)。

```groovy
gremlin> :remote config timeout 180000
==>Set remote timeout to 180000ms
gremlin> :> ManagementSystem.awaitGraphIndexStatus(graph, 'by_name').status(SchemaStatus.REGISTERED).timeout(3, java.time.temporal.ChronoUnit.MINUTES).call()
```

状態が`REGISTERED`になるまで、待機する。上記の場合、時間は3分にしてある。成功すれば以下のように応答が返ってくる(success=true)。

```
==>GraphIndexStatusReport[success=true, indexName='by_name', targetStatus=[REGISTERED], notConverged={}, converged={name=REGISTERED}, elapsed=PT10.526S]
```

問題はいつまでも`REGISTERED`にならない場合がある場合が何度かあったことだ。それについては残念ながら解決していない(データベース毎削除してやり直した)。

`printSchema`を再度実行して、インデックスの状態を確認する。

```groovy
gremlin> :> m = graph.openManagement()
==>org.janusgraph.graphdb.database.management.ManagementSystem@42e41e2e
gremlin> :> m.printSchema()
(略)
---------------------------------------------------------------------------------------------------
Property Key Name              | Cardinality | Data Type                                          |
---------------------------------------------------------------------------------------------------
name                           | SINGLE      | class java.lang.String                             |
age                            | SINGLE      | class java.lang.Integer                            |
---------------------------------------------------------------------------------------------------
Vertex Index Name              | Type        | Unique    | Backing        | Key:           Status |
---------------------------------------------------------------------------------------------------
by_name                        | Composite   | false     | internalindex  | name:       REGISTERED|
---------------------------------------------------------------------------------------------------
(略)
```

ここで、インデックス`by_name`の右端のStatusが`REGISTERED`となっていればOK。次は`REINDEX`を実行して、現在のグラフデータに対してインデックステーブルを更新する。

```groovy
gremlin> :> by_name = m.getGraphIndex("by_name")
==>by_name
gremlin> :> m.updateIndex(by_name, SchemaAction.REINDEX).get()
==>org.janusgraph.diskstorage.keycolumnvalue.scan.StandardScanMetrics@451782db
gremlin> :> m.commit()
==>null
gremlin> 
```

これを実行した後、インデックステーブルの更新が終わっていれば再度`printSchema`を実行したときに状態が`ENABLED`となり、インデックスが有効となる。

```groovy
gremlin> :> m = graph.openManagement()
==>org.janusgraph.graphdb.database.management.ManagementSystem@42e41e2e
gremlin> :> m.printSchema()
(略)
---------------------------------------------------------------------------------------------------
Vertex Index Name              | Type        | Unique    | Backing        | Key:           Status |
---------------------------------------------------------------------------------------------------
by_name                        | Composite   | false     | internalindex  | name:         ENABLED |
---------------------------------------------------------------------------------------------------
gremlin> :> m.rollback()
==>null
```

なお、特に変更しない場合は`m.rollback`を使って`m`を処分するとよい。

インデックスの状態遷移については公式に図があるので、参照していただきたい。

[Index Lifecycle](https://docs.janusgraph.org/index-management/index-lifecycle/)

## 自動化
こういった作業を毎回手作業でやるのは辛いので、スクリプトを書いて自動化する。Gremlinコンソールでは`:load`コマンドを使ってファイルからコマンドのリストを自動実行できる。

```makeindex.groovy
:remote connect tinkerpop.server conf/remote.yaml session
:remote config timeout 180000
:> prop_name = "name"
:> index_name = "by_name"
:> m = graph.openManagement()
:> name = m.getPropertyKey(prop_name)
:> m.buildIndex(index_name, Vertex.class).addKey(name).buildCompositeIndex()
:> m.commit()
:> ManagementSystem.awaitGraphIndexStatus(graph, index_name).status(SchemaStatus.REGISTERED).timeout(3, java.time.temporal.ChronoUnit.MINUTES).call()
:> m = graph.openManagement()
:> m.printSchema()
:> by_name = m.getGraphIndex(index_name)
:> m.updateIndex(by_name, SchemaAction.REINDEX).get()
:> m.commit()
:> ManagementSystem.awaitGraphIndexStatus(graph, index_name).status(SchemaStatus.ENABLED).timeout(3, java.time.temporal.ChronoUnit.MINUTES).call()
:> m = graph.openManagement()
:> m.printSchema()
:> m.rollback()
:remote close
```

このファイルをJanusGraphのルートディレクトリから見える場所に置いておく。例えば`./makeindex.groovy`に置いたとすると、

```sh
$ bin/gremlin.sh
```

でコンソールを起動したのち、

```groovy
gremlin> :load ./makeindex.groovy
```

で自動実行できる。

## インデックス作成後のクエリ応答時間

|頂点数|インデックスあり[ミリ秒]|インデックスなし[ミリ秒]|
|---|---|---|
|1000|5|15|
|10000|4|130|
|100000|4|1605|

これなら実用に耐えそう！👍

## 複合インデックスの場合は？

```groovy
name = mgmt.getPropertyKey('name')
age = mgmt.getPropertyKey('age')
mgmt.buildIndex('by_name_age', Vertex.class).addKey(name).addKey(age).buildCompositeIndex()
```

`addKey`をつなげるだけ。あとは同じ。