CLOVER🍀

That was when it all began.

Infinispan 14.0の新しいインデックス用のアノテーションをHot Rodで試す

これは、なにをしたくて書いたもの?

Infinispanで全文検索インデックスを使用する場合、Cacheに保存するエントリーの各プロパティに対してHibernate Searchの
アノテーションを使用して設定を行っていました。

これがInfinispan 14.0で、Infinispan自身がインデックス用のアノテーションを提供するようになったようです。

Native Infinispan indexing annotations which finally replace the legacy Hibernate Query annotations we’ve used in past versions

Infinispan 14.0.0.Final

インデックスに関する変更は他にもあるようですが、今回はアノテーションに関して見ていこうと思います。

Index startup mode to determine what happens to indexes on cache start

Dynamic index schema updates allow you to evolve your schema at runtime with near-zero impact to your queries

Infinispanによる新しいインデックス用アノテーション

まず、Infinispanのクエリーやインデックスに関するドキュメントは、こちらです。

Querying Infinispan caches

Infinispanのクエリーのうち、インデックスを使ったものはHibernate SearchとApache Luceneによって実現されています。

クエリーは、Ickle QueryというJPQLライクな言語を使って記述します。

Querying Infinispan caches / Creating Ickle queries

今回の新しいアノテーションについてはドキュメントには書かれていないようなので、以下のブログやソースコードを頼りに見ていくことに
します。

Infinispan 14 indexing & query news

なお、この変更が入ったのは以下のPull Requestのようです。

ISPN-9893 New API indexing annotations by fax4ever · Pull Request #9942 · infinispan/infinispan · GitHub

ブログエントリーには、このようなアノテーションの紹介は行われているのですが、import文もないのでどのパッケージなのかも
わかりません。

@Indexed
public class Poem {
    private Author author;
    private String description;
    private Integer year;

    @Embedded(includeDepth = 2, structure = Structure.NESTED)
    public Author getAuthor() {
        return author;
    }

    @Text(projectable = true, analyzer = "whitespace", termVector = TermVector.WITH_OFFSETS)
    public String getDescription() {
        return description;
    }

    @Basic(projectable = true, sortable = true, indexNullAs = "1800")
    public Integer getYear() {
    return year;
    }
}

@Indexed
public class Author {
    private String name;

    public Author(String name) {
        this.name = name;
    }

    @Keyword(projectable = true, sortable = true, normalizer = "lowercase", indexNullAs = "unnamed", norms = false)
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

説明は少し書かれています。

まず、先にも書きましたが。今回のアノテーションはHibernate Searchのアノテーションを置き換えるInfinispanのインデックス用アノテーションに
なります。Embedded、Remoteのどちらでも使えるようです。

We are going to replace Hibernate annotations with Infinispan indexing annotations. The new annotations can be used in the same way for both embedded and remote queries.

アノテーション自体にも触れています。

  • @Basic … 特に文字列変換処理を行わないフィールド
  • @Keyword … Stringフィールドにノーラマイザーを適用する
  • @Text … Stringフィールドにアナライザーを適用する

ソートやプロジェクションができるかどうかは、sortableやprojectableで指定します。使用できない組み合わせもあり、たとえばsortableと
アナライズを行う@Textは組み合わせることができません。

ネストしたオブジェクトをインデックスに保存する際には、@Embeddedアノテーションを使用します。この時、ネストしたオブジェクトを
どのようにインデックスに保存するかはstructure属性で指定します。NESTEDがデフォルトで、オブジェクトの構造を維持します。
つまり、ネストしたオブジェクトは別のインデックスエントリーとして扱われます。もうひとつはFLATTENEDで、ネストしたオブジェクトは
親オブジェクトの一部として保存されます。

Infinispanのソースコードから、該当のアノテーションを探してみましょう。org.infinispan.api.annotations.indexingパッケージ配下に
あるようです。

$ grep -rE 'Simplified version for Infinispan of|Infinispan version of' api/src/main/java/org/infinispan/api/annotations/indexing
api/src/main/java/org/infinispan/api/annotations/indexing/option/TermVector.java: * Simplified version for Infinispan of {@link org.hibernate.search.engine.backend.types.TermVector}
api/src/main/java/org/infinispan/api/annotations/indexing/option/Structure.java: * Simplified version for Infinispan of {@link org.hibernate.search.engine.backend.types.ObjectStructure}
api/src/main/java/org/infinispan/api/annotations/indexing/model/Point.java: * Simplified version for Infinispan of {@link org.hibernate.search.engine.spatial.GeoPoint}
api/src/main/java/org/infinispan/api/annotations/indexing/GeoCoordinates.java: * Infinispan version of {@link org.hibernate.search.mapper.pojo.bridge.builtin.annotation.GeoPointBinding}
api/src/main/java/org/infinispan/api/annotations/indexing/Longitude.java: * Infinispan version of {@link org.hibernate.search.mapper.pojo.bridge.builtin.annotation.Longitude}
api/src/main/java/org/infinispan/api/annotations/indexing/Text.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField}
api/src/main/java/org/infinispan/api/annotations/indexing/Embedded.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexedEmbedded}
api/src/main/java/org/infinispan/api/annotations/indexing/Latitude.java: * Infinispan version of {@link org.hibernate.search.mapper.pojo.bridge.builtin.annotation.Latitude}
api/src/main/java/org/infinispan/api/annotations/indexing/Keyword.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField}
api/src/main/java/org/infinispan/api/annotations/indexing/Decimal.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.ScaledNumberField}
api/src/main/java/org/infinispan/api/annotations/indexing/Basic.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField}
api/src/main/java/org/infinispan/api/annotations/indexing/Indexed.java: * Simplified version for Infinispan of {@link org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed}

https://github.com/infinispan/infinispan/tree/14.0.3.Final/api/src/main/java/org/infinispan/api/annotations/indexing

コメントを見ると、Hibernate SearchのアノテーションのInfinispan版であることが書かれていますね。

対応表を作ってみましょう。

Infinispanのアノテーション Hibernate Searchのアノテーション
org.infinispan.api.annotations.indexing.@Indexed org.hibernate.search.mapper.pojo.mapping.definition.annotation.@Indexed
org.infinispan.api.annotations.indexing.@Text org.hibernate.search.mapper.pojo.mapping.definition.annotation.@FullTextField
org.infinispan.api.annotations.indexing.@Basic org.hibernate.search.mapper.pojo.mapping.definition.annotation.@GenericField
org.infinispan.api.annotations.indexing.@Keyword org.hibernate.search.mapper.pojo.mapping.definition.annotation.@KeywordField
org.infinispan.api.annotations.indexing.@Decimal org.hibernate.search.mapper.pojo.mapping.definition.annotation.@ScaledNumberField
org.infinispan.api.annotations.indexing.@GeoCoordinates org.hibernate.search.mapper.pojo.bridge.builtin.annotation.@GeoPointBinding
org.infinispan.api.annotations.indexing.@Latitude org.hibernate.search.mapper.pojo.bridge.builtin.annotation.@Latitude
org.infinispan.api.annotations.indexing.@Longitude org.hibernate.search.mapper.pojo.bridge.builtin.annotation.@Longitude
org.infinispan.api.annotations.indexing.@Embedded org.hibernate.search.mapper.pojo.mapping.definition.annotation.@IndexedEmbedded
Infinispanのクラスやenum Hibernate Searchのクラスやenum
org.infinispan.api.annotations.indexing.model.Point org.hibernate.search.engine.spatial.GeoPoint
org.infinispan.api.annotations.indexing.option.Structure org.hibernate.search.engine.backend.types.ObjectStructure
org.infinispan.api.annotations.indexing.option.TermVector org.hibernate.search.engine.backend.types.TermVector

このあたりの説明は、Hibernate Searchのドキュメントを見るのがよいでしょう。

Infinispan側は、あとはテストコードを参考に。

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/dsl/embedded/testdomain

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/model

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/api

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/indexedembedded

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/queries

https://github.com/infinispan/infinispan/tree/14.0.3.Final/query/src/test/java/org/infinispan/query/test

https://github.com/infinispan/infinispan/tree/14.0.3.Final/client/hotrod-client/src/test/java/org/infinispan/client/hotrod/annotation/model

今回は、新しいInifinispanのインデックス用アノテーションを使って、インデックスとクエリーを使ってみましょう。

少し脱線: Elasticsearchバックエンド

その前に、少し脱線して。

そういえば、Infinispan 9.0から実験的な機能としてElasticsearchをバックエンドにした検索機能がありましたが、11.0で削除されていた
みたいですね。

[ISPN-11646] Drop Elasticsearch support - Red Hat Issue Tracker

ISPN-11646 Drop Support for Elasticsearch by gustavocoding · Pull Request #8204 · infinispan/infinispan · GitHub

いや、けっこう前から気づいていたんですが、挙げる機会がなかったので…。

お題

今回のお題は、以下のようにしたいと思います。

  • Hot Rod Clientを使う(Remote Cacheを使う)
  • Cacheに格納するエンティティは、インデックス定義なし、ありの2種類を使う
    • Cacheもそれぞれに作成する
  • どちらのCacheにも、クエリーを投げてみる

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu120.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-135-generic", arch: "amd64", family: "unix"

Infinispan Serverは、172.18.0.2〜172.18.0.4の3つのノードで動作し、クラスターを構成しているものとします。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment Temurin-17.0.5+8 (build 17.0.5+8)
OpenJDK 64-Bit Server VM Temurin-17.0.5+8 (build 17.0.5+8, mixed mode, sharing)


$ bin/server.sh --version

Infinispan Server 14.0.3.Final (Flying Saucer)
Copyright (C) Red Hat Inc. and/or its affiliates and other contributors
License Apache License, v. 2.0. http://www.apache.org/licenses/LICENSE-2.0

起動コマンドは、こちら。

$ bin/server.sh \
    -b 0.0.0.0 \
    -Djgroups.tcp.address=$(hostname -i)

準備

Maven依存関係など。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-api</artifactId>
            <version>14.0.3.Final</version>
        </dependency>
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-client-hotrod</artifactId>
            <version>14.0.3.Final</version>
        </dependency>
        <dependency>
           <groupId>org.infinispan</groupId>
           <artifactId>infinispan-remote-query-client</artifactId>
            <version>14.0.3.Final</version>
        </dependency>
        <dependency>
           <groupId>org.infinispan</groupId>
           <artifactId>infinispan-query-dsl</artifactId>
            <version>14.0.3.Final</version>
        </dependency>
        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>4.5.0.Final</version>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-core</artifactId>
            <version>14.0.3.Final</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.23.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

依存関係として、infinispan-apiを追加しておきます。また、Remote Queryを使うのでinfinispan-client-hotrod、infinispan-remote-query-client、
infinispan-query-dslも使用します。

各Infinispan Serverには、それぞれ管理用ユーザー、アプリケーション用ユーザーを作成しておきます。

$ bin/cli.sh user create -g admin -p password ispn-admin
$ bin/cli.sh user create -g application -p password ispn-user

テストコードの雛形を作成する

動作確認は、テストコードで行います。まずはその雛形を書いておきましょう。

src/test/java/org/littlewings/infinispan/remote/query/RemoteQueryTest.java

package org.littlewings.infinispan.remote.query;

import java.util.List;
import java.util.function.Consumer;

import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.RemoteCacheManager;
import org.infinispan.client.hotrod.RemoteCacheManagerAdmin;
import org.infinispan.client.hotrod.Search;
import org.infinispan.configuration.cache.CacheMode;
import org.infinispan.configuration.cache.ConfigurationBuilder;
import org.infinispan.configuration.cache.IndexStartupMode;
import org.infinispan.configuration.cache.IndexStorage;
import org.infinispan.query.dsl.Query;
import org.infinispan.query.dsl.QueryFactory;
import org.infinispan.query.remote.client.ProtobufMetadataManagerConstants;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class RemoteQueryTest {
    static String createUri(String userName, String password) {
        return String.format(
                "hotrod://%s:%s@172.18.0.2:11222,172.18.0.3:11222,172.18.0.4:11222"
                        + "?context-initializers=org.littlewings.infinispan.remote.query.EntitiesInitializerImpl",
                userName,
                password
        );
    }

    @BeforeAll
    static void setUpAll() {
        String uri = createUri("ispn-admin", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            // create permission
            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

            RemoteCacheManagerAdmin admin = manager.administration();

            org.infinispan.configuration.cache.Configuration indexLessDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(org.infinispan.configuration.cache.CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            .build();

            admin.getOrCreateCache("bookCache", indexLessDistCacheConfiguration);

            org.infinispan.configuration.cache.Configuration indexedDistCacheConfiguration =
                    new ConfigurationBuilder()
                            .clustering()
                            .cacheMode(CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)
                            .writer().commitInterval(1000)
                            .build();

            admin.getOrCreateCache("indexedBookCache", indexedDistCacheConfiguration);
        }
    }

    <K, V> void withCache(String cacheName, Consumer<RemoteCache<K, V>> func) {
        String uri = createUri("ispn-user", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            RemoteCache<K, V> cache = manager.getCache(cacheName);

            func.accept(cache);
        }
    }

    // ここに、テストを書く!!

}

Cacheを作成する処理などがあるのですが、それは後で説明します。

インデックスなしでクエリーを使う

最初は、インデックスなしでクエリーを使ってみましょう。

エンティティの定義。

src/main/java/org/littlewings/infinispan/remote/query/Book.java

package org.littlewings.infinispan.remote.query;

import org.infinispan.protostream.annotations.ProtoFactory;
import org.infinispan.protostream.annotations.ProtoField;
import org.infinispan.protostream.descriptors.Type;

public class Book {
    @ProtoField(number = 1, type = Type.STRING)
    String isbn;

    @ProtoField(number = 2, type = Type.STRING)
    String title;

    @ProtoField(number = 3, type = Type.INT32, defaultValue = "0")
    int price;

    @ProtoFactory
    public static Book create(String isbn, String title, int price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

MarshallingはProtoStreamとします。

SerializationContextInitializerインターフェースを継承したインターフェースを作成。

src/main/java/org/littlewings/infinispan/remote/query/EntitiesInitializer.java

package org.littlewings.infinispan.remote.query;

import org.infinispan.protostream.SerializationContextInitializer;
import org.infinispan.protostream.annotations.AutoProtoSchemaBuilder;

@AutoProtoSchemaBuilder(
        includeClasses = {Book.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity"
)
public interface EntitiesInitializer extends SerializationContextInitializer {
}

このインターフェースを作成し、依存関係にprotostream-processorを加えているとコンパイルした時にProtocol BuffersのIDLが
生成されるようになります。

target/classes/proto/entities.proto

// File name: entities.proto
// Generated from : org.littlewings.infinispan.remote.query.EntitiesInitializer

syntax = "proto2";

package entity;



message Book {

   optional string isbn = 1;

   optional string title = 2;

   optional int32 price = 3 [default = 0];
}

次に、テストコードに移ります。

接続URLの作成から。context-initializersパラメーターに、先ほど作成したSerializationContextInitializerインターフェースを継承したものから
生成された実装クラスのFQCNを指定する必要があります。

    static String createUri(String userName, String password) {
        return String.format(
                "hotrod://%s:%s@172.18.0.2:11222,172.18.0.3:11222,172.18.0.4:11222"
                        + "?context-initializers=org.littlewings.infinispan.remote.query.EntitiesInitializerImpl",
                userName,
                password
        );
    }

Cacheを作成しましょう。

    @BeforeAll
    static void setUpAll() {
        String uri = createUri("ispn-admin", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            // create permission
            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

            RemoteCacheManagerAdmin admin = manager.administration();

            org.infinispan.configuration.cache.Configuration indexLessDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(org.infinispan.configuration.cache.CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            .build();

            admin.getOrCreateCache("bookCache", indexLessDistCacheConfiguration);

            〜省略〜
        }
    }

生成されたProtocol BuffersのIDLをInfinispan Serverに登録します。

            // create permission
            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

IDLを登録する方法は、Web UI、CLI、REST API、Hot Rod ClientからAPI呼び出し、SerializationContextInitializerインターフェースの実装を
含んだJARファイルをInfinispan Serverにデプロイ…といくつか方法がありますが、今回はHot Rod ClientからAPI呼び出しで登録する
ことにしました。

Encoding Infinispan caches and marshalling data / Marshalling custom objects with ProtoStream / Creating serialization context initializers / Registering Protobuf schemas with Infinispan Server

登録したIDLは、CLIで確認することもできます。

[infinispan-server-29760@cluster//containers/default]> schema ls
[{"name":"entities.proto","error":null}]
[infinispan-server-29760@cluster//containers/default]> schema get entities.proto
[infinispan-server-29760@cluster//containers/default]> schema get entities.proto
// File name: entities.proto
// Generated from : org.littlewings.infinispan.remote.query.EntitiesInitializer

syntax = "proto2";

package entity;



message Book {

   optional string isbn = 1;

   optional string title = 2;

   optional int32 price = 3 [default = 0];
}

また、先ほどのコードで登録されるDistributed Cacheの定義は以下になります。

server/data/caches.xml

<?xml version="1.0"?>
<infinispan xmlns="urn:infinispan:config:14.0">
    <cache-container>
        <caches>
            <distributed-cache name="bookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <state-transfer timeout="60000"/>
            </distributed-cache>
        </caches>
    </cache-container>
</infinispan>

あとはデータを登録して、クエリーを投げるプログラムを書けばOKです。

    @Test
    public void indexLessQuery() {
        this.<String, Book>withCache("bookCache", cache -> {
            List<Book> books = List.of(
                    Book.create("978-1782169970", "Infinispan Data Grid Platform Definitive Guide", 5242),
                    Book.create("978-4048917353", "Redis入門 インメモリKVSによる高速データ管理", 3400),
                    Book.create("978-4798045733", "RDB技術者のためのNoSQLガイド", 3400),
                    Book.create("978-1785285332", "Getting Started with Hazelcast - Second Edition", 4129),
                    Book.create("978-1789347531", "Apache Ignite Quick Start Guide: Distributed data caching and processing made easy", 3476)
            );

            books.forEach(book -> cache.put(book.getIsbn(), book));

            assertThat(cache.size()).isEqualTo(5);

            QueryFactory queryFactory = Search.getQueryFactory(cache);
            Query<Book> query =
                    queryFactory.create("from entity.Book where price > :price order by price desc");
            query.setParameter("price", 4000);

            List<Book> results = query.execute().list();

            assertThat(results).hasSize(2);
            assertThat(results.get(0).getTitle()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");
            assertThat(results.get(1).getTitle()).isEqualTo("Getting Started with Hazelcast - Second Edition");
        });
    }

Ickle Queryを使うためのAPIの紹介はこちら。

Querying Infinispan caches / Creating Ickle queries / Ickle queries

そして、インデックスを使わない場合のクエリーの構文はこちらです。

Querying Infinispan caches / Creating Ickle queries / Ickle query language syntax

今回のクエリーはDistributed Cacheに対してソートを行っていますが

            Query<Book> query =
                    queryFactory.create("from entity.Book where price > :price order by price desc");

インデックスがない状態でこのようなソートを行うと、Infinispan Server側でWARNログが出力されることになります。

2022-12-02 15:11:52,527 WARN  (blocking-thread--p3-t1) [org.infinispan.query.core.impl.BaseEmbeddedQuery] ISPN014827: Distributed sort not supported for non-indexed query 'from entity.Book where price > :price order by price desc'. Consider using an index for optimal performance.

パフォーマンスが良くないですよ、ということですね。

インデックスを定義してクエリーを実行する

続いては、インデックスを定義してクエリーを実行してみましょう。

エンティティの定義。

src/main/java/org/littlewings/infinispan/remote/query/IndexedBook.java

package org.littlewings.infinispan.remote.query;

import org.infinispan.api.annotations.indexing.Basic;
import org.infinispan.api.annotations.indexing.Indexed;
import org.infinispan.api.annotations.indexing.Keyword;
import org.infinispan.api.annotations.indexing.Text;
import org.infinispan.protostream.annotations.ProtoFactory;
import org.infinispan.protostream.annotations.ProtoField;
import org.infinispan.protostream.descriptors.Type;

@Indexed
public class IndexedBook {
    @Keyword(sortable = true)
    @ProtoField(number = 1, type = Type.STRING)
    String isbn;

    // デフォルトのAnalyzerはstandard
    @Text(analyzer = "standard")
    @ProtoField(number = 2, type = Type.STRING)
    String title;

    @Basic(sortable = true)
    @ProtoField(number = 3, type = Type.INT32, defaultValue = "0")
    int price;

    @ProtoFactory
    public static IndexedBook create(String isbn, String title, int price) {
        IndexedBook book = new IndexedBook();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

ここで、今回の主題であるInfinispanのインデックス用アノテーションが出てきます。

すでにアノテーション自体は紹介していますが、これらのアノテーションはinfinispan-apiに含まれています。

        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-api</artifactId>
            <version>14.0.3.Final</version>
        </dependency>

SerializationContextInitializerインターフェースを継承したインターフェース。

src/main/java/org/littlewings/infinispan/remote/query/EntitiesInitializer.java

package org.littlewings.infinispan.remote.query;

import org.infinispan.protostream.SerializationContextInitializer;
import org.infinispan.protostream.annotations.AutoProtoSchemaBuilder;

@AutoProtoSchemaBuilder(
        includeClasses = {IndexedBook.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity"
)
public interface EntitiesInitializer extends SerializationContextInitializer {
}

生成されたProtocol BuffersのIDL。アノテーションの記述がコメントとして残っています。

target/classes/proto/entities.proto

// File name: entities.proto
// Generated from : org.littlewings.infinispan.remote.query.EntitiesInitializer

syntax = "proto2";

package entity;



/**
 * @Indexed
 */
message IndexedBook {

   /**
    * @Keyword(sortable=true)
    */
   optional string isbn = 1;

   /**
    * @Text(analyzer="standard")
    */
   optional string title = 2;

   /**
    * @Basic(sortable=true)
    */
   optional int32 price = 3 [default = 0];
}

Cacheの定義。

    @BeforeAll
    static void setUpAll() {
        String uri = createUri("ispn-admin", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            // create permission
            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

            RemoteCacheManagerAdmin admin = manager.administration();

            〜省略〜

            org.infinispan.configuration.cache.Configuration indexedDistCacheConfiguration =
                    new ConfigurationBuilder()
                            .clustering()
                            .cacheMode(CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")
                            //.path("index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)
                            .writer().commitInterval(1000)
                            .build();

            admin.getOrCreateCache("indexedBookCache", indexedDistCacheConfiguration);
        }
    }

Infinispan Serverに登録されたProtocol BuffersのIDL。

[infinispan-server-48098@cluster//containers/default]> schema get entities.proto
// File name: entities.proto
// Generated from : org.littlewings.infinispan.remote.query.EntitiesInitializer

syntax = "proto2";

package entity;



/**
 * @Indexed
 */
message IndexedBook {

   /**
    * @Keyword(sortable=true)
    */
   optional string isbn = 1;

   /**
    * @Text(analyzer="standard")
    */
   optional string title = 2;

   /**
    * @Basic(sortable=true)
    */
   optional int32 price = 3 [default = 0];
}

Infinispan Server側で生成されるCacheの定義。

server/data/caches.xml

<?xml version="1.0"?>
<infinispan xmlns="urn:infinispan:config:14.0">
    <cache-container>
        <caches>
            <distributed-cache name="indexedBookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <indexing enabled="true" storage="filesystem" startup-mode="REINDEX" path="/opt/infinispan-server/server/data/index/indexedBookCache">
                    <index-writer commit-interval="1000"/>
                    <indexed-entities>
                        <indexed-entity>entity.IndexedBook</indexed-entity>
                    </indexed-entities>
                </indexing>
                <state-transfer timeout="60000"/>
            </distributed-cache>
        </caches>
    </cache-container>
</infinispan>

このあたりがインデックスの設定なのですが

                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")
                            //.path("index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)
                            .writer().commitInterval(1000)

説明は、このあたりにありますね。

Querying Infinispan caches / Indexing Infinispan caches / Configuring Infinispan to index caches / Index configuration

インデックスを有効にして

                            .indexing()
                            .enable()

インデックス保存の対象とするエンティティを登録。

                            .addIndexedEntities("entity.IndexedBook")

インデックスの保存場所は、今回はファイルシステムとしました。

                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")

ちなみに、こう書いても保存場所は変わりません。

                            .storage(IndexStorage.FILESYSTEM)
                            .path("index/indexedBookCache")

この場合、${infinispan.server.data.path}からの相対パスとして解決されます。

                <indexing enabled="true" storage="filesystem" startup-mode="REINDEX" path="index/indexedBookCache">
                    <index-writer commit-interval="1000"/>
                    <indexed-entities>
                        <indexed-entity>entity.IndexedBook</indexed-entity>
                    </indexed-entities>
                </indexing>

こちらについては、後で少し書きます。

クエリーを使ったテストコード。

    @Test
    public void indexedQuery() {
        this.<String, IndexedBook>withCache("indexedBookCache", cache -> {
            List<IndexedBook> books = List.of(
                    IndexedBook.create("978-1782169970", "Infinispan Data Grid Platform Definitive Guide", 5242),
                    IndexedBook.create("978-4048917353", "Redis入門 インメモリKVSによる高速データ管理", 3400),
                    IndexedBook.create("978-4798045733", "RDB技術者のためのNoSQLガイド", 3400),
                    IndexedBook.create("978-1785285332", "Getting Started with Hazelcast - Second Edition", 4129),
                    IndexedBook.create("978-1789347531", "Apache Ignite Quick Start Guide: Distributed data caching and processing made easy", 3476)
            );

            books.forEach(book -> cache.put(book.getIsbn(), book));

            assertThat(cache.size()).isEqualTo(5);

            QueryFactory queryFactory = Search.getQueryFactory(cache);

            Query<IndexedBook> query1 =
                    queryFactory.create("from entity.IndexedBook where title: 'guide' and price: [4000 to *] order by price");

            List<IndexedBook> results1 = query1.execute().list();

            assertThat(results1).hasSize(1);
            assertThat(results1.get(0).getTitle()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");

            Query<IndexedBook> query2 =
                    queryFactory.create("from entity.IndexedBook where isbn = '978-4048917353'");

            List<IndexedBook> results2 = query2.execute().list();

            assertThat(results2).hasSize(1);
            assertThat(results2.get(0).getTitle()).isEqualTo("Redis入門 インメモリKVSによる高速データ管理");

            Query<IndexedBook> query3 =
                    queryFactory.create("from entity.IndexedBook where (title: 'data' and title: 'guide') or price: [* to 3400] order by price desc, isbn asc");

            List<IndexedBook> results3 = query3.execute().list();

            assertThat(results3).hasSize(4);
            assertThat(results3.get(0).getTitle()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");
            assertThat(results3.get(1).getTitle()).isEqualTo("Apache Ignite Quick Start Guide: Distributed data caching and processing made easy");
            assertThat(results3.get(2).getTitle()).isEqualTo("Redis入門 インメモリKVSによる高速データ管理");
            assertThat(results3.get(3).getTitle()).isEqualTo("RDB技術者のためのNoSQLガイド");
        });
    }

インデックスを有効にしているフィールドに対するクエリー構文は、こちら。

Querying Infinispan caches / Creating Ickle queries / Full-text queries

このあたりですね。

            Query<IndexedBook> query1 =
                    queryFactory.create("from entity.IndexedBook where title: 'guide' and price: [4000 to *] order by price");


            〜省略〜


            Query<IndexedBook> query3 =
                    queryFactory.create("from entity.IndexedBook where (title: 'data' and title: 'guide') or price: [* to 3400] order by price desc, isbn asc");

※ フルテキストクエリーに対しては、バインドパラメータは一部動作しないようなので、今回対象外にしています

@Keywordアノテーションはアナライズを行わないため、通常のクエリーの構文になります。

            Query<IndexedBook> query2 =
                    queryFactory.create("from entity.IndexedBook where isbn = '978-4048917353'");

また、ソートをしたいフィールドに対しては

            Query<IndexedBook> query1 =
                    queryFactory.create("from entity.IndexedBook where title: 'guide' and price: [4000 to *] order by price");


            〜省略〜


            Query<IndexedBook> query3 =
                    queryFactory.create("from entity.IndexedBook where (title: 'data' and title: 'guide') or price: [* to 3400] order by price desc, isbn asc");

明示的にsortableをtrueにする必要があります。

    @Keyword(sortable = true)
    @ProtoField(number = 1, type = Type.STRING)
    String isbn;

    〜省略〜

    @Basic(sortable = true)
    @ProtoField(number = 3, type = Type.INT32, defaultValue = "0")
    int price;

アナライズを行う@Textのようなフィールドに対しては、ソートは有効にできません。

保存されたインデックスは、こちら。

$ ll server/data/index/indexedBookCache/entity.IndexedBook
total 24
drwxr-xr-x 2 xxxxx xxxxx 4096 Dec  2 15:32 ./
drwxr-xr-x 3 xxxxx xxxxx 4096 Dec  2 15:32 ../
-rw-r--r-- 1 xxxxx xxxxx  479 Dec  2 15:32 _0.cfe
-rw-r--r-- 1 xxxxx xxxxx 3632 Dec  2 15:32 _0.cfs
-rw-r--r-- 1 xxxxx xxxxx  373 Dec  2 15:32 _0.si
-rw-r--r-- 1 xxxxx xxxxx  154 Dec  2 15:32 segments_2
-rw-r--r-- 1 xxxxx xxxxx    0 Dec  2 15:32 write.lock

とりあえず、簡単にですがInfinispanの新しいインデックス用のアノテーションを試してみました。

2つのエンティティの内容を合わせた定義

ここまで2つのエンティティをそれぞれ別々に使いましたが、合わせた時の定義も載せておきます。オマケ的ですが。

src/main/java/org/littlewings/infinispan/remote/query/EntitiesInitializer.java

package org.littlewings.infinispan.remote.query;

import org.infinispan.protostream.SerializationContextInitializer;
import org.infinispan.protostream.annotations.AutoProtoSchemaBuilder;

@AutoProtoSchemaBuilder(
        includeClasses = {Book.class, IndexedBook.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity"
)
public interface EntitiesInitializer extends SerializationContextInitializer {
}
[infinispan-server-63828@cluster//containers/default]> schema get entities.proto
// File name: entities.proto
// Generated from : org.littlewings.infinispan.remote.query.EntitiesInitializer

syntax = "proto2";

package entity;



/**
 * @Indexed
 */
message IndexedBook {

   /**
    * @Keyword(sortable=true)
    */
   optional string isbn = 1;

   /**
    * @Text(analyzer="standard")
    */
   optional string title = 2;

   /**
    * @Basic(sortable=true)
    */
   optional int32 price = 3 [default = 0];
}


message Book {

   optional string isbn = 1;

   optional string title = 2;

   optional int32 price = 3 [default = 0];
}

こちらの2つのCacheを作成した際に、

            org.infinispan.configuration.cache.Configuration indexLessDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(org.infinispan.configuration.cache.CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            .build();

            admin.getOrCreateCache("bookCache", indexLessDistCacheConfiguration);

            org.infinispan.configuration.cache.Configuration indexedDistCacheConfiguration =
                    new ConfigurationBuilder()
                            .clustering()
                            .cacheMode(CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")
                            //.path("index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)
                            .writer().commitInterval(1000)
                            .build();

            admin.getOrCreateCache("indexedBookCache", indexedDistCacheConfiguration);

Infinispan Server側で生成されるCache定義。

server/data/caches.xml

<?xml version="1.0"?>
<infinispan xmlns="urn:infinispan:config:14.0">
    <cache-container>
        <caches>
            <distributed-cache name="bookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <state-transfer timeout="60000"/>
            </distributed-cache>
            <distributed-cache name="indexedBookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <indexing enabled="true" storage="filesystem" startup-mode="REINDEX" path="/opt/infinispan-server/server/data/index/indexedBookCache">
                    <index-writer commit-interval="1000"/>
                    <indexed-entities>
                        <indexed-entity>entity.IndexedBook</indexed-entity>
                    </indexed-entities>
                </indexing>
                <state-transfer timeout="60000"/>
            </distributed-cache>
        </caches>
    </cache-container>
</infinispan>

少し実装を追ってみる

今回はInfinispanの新しいインデックス用のアノテーションを見てきたのですが、これらはHibernate Searchとどのような関係があるのでしょうか?

答えは、このあたりにありそうです。

https://github.com/infinispan/infinispan/tree/14.0.3.Final/api/src/main/java/org/infinispan/api/common/annotations/indexing/_private

TextProcessorを例に見てみましょう。

package org.infinispan.api.common.annotations.indexing._private;

import org.hibernate.search.mapper.pojo.mapping.definition.annotation.processing.PropertyMappingAnnotationProcessor;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.processing.PropertyMappingAnnotationProcessorContext;
import org.hibernate.search.mapper.pojo.mapping.definition.programmatic.PropertyMappingFullTextFieldOptionsStep;
import org.hibernate.search.mapper.pojo.mapping.definition.programmatic.PropertyMappingStep;
import org.infinispan.api.annotations.indexing.Text;

public class TextProcessor implements PropertyMappingAnnotationProcessor<Text> {

   @Override
   public void process(PropertyMappingStep mapping, Text annotation, PropertyMappingAnnotationProcessorContext context) {
      String name = annotation.name();
      PropertyMappingFullTextFieldOptionsStep fullTextField = (name.isEmpty()) ?
            mapping.fullTextField() : mapping.fullTextField(name);

      fullTextField.analyzer(annotation.analyzer());

      String searchAnalyzer = annotation.searchAnalyzer();
      if (!searchAnalyzer.isEmpty()) {
         fullTextField.searchAnalyzer(searchAnalyzer);
      }

      fullTextField.norms(Options.norms(annotation.norms()));
      fullTextField.termVector(Options.termVector(annotation.termVector()));
      fullTextField.projectable(Options.projectable(annotation.projectable()));
      fullTextField.searchable(Options.searchable(annotation.searchable()));
   }
}

https://github.com/infinispan/infinispan/blob/14.0.3.Final/api/src/main/java/org/infinispan/api/common/annotations/indexing/_private/TextProcessor.java

どうやら、Hibernate Searchのカスタムマッピングアノテーションという仕組みを使っているみたいですね。

Hibernate Search: Reference Documentation / Mapping Hibernate ORM entities to indexes / Custom mapping annotations

@TextアノテーションおよびTextProcessorの宣言を見ると、関連があるのがわかりますね。

@Documented
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Text.List.class)
@PropertyMapping(processor = @PropertyMappingAnnotationProcessorRef(type = TextProcessor.class, retrieval = BeanRetrieval.CONSTRUCTOR))
public @interface Text {


public class TextProcessor implements PropertyMappingAnnotationProcessor<Text> {

この仕組みがいつ動作しているかというと、フィールドにアノテーションが付与されているのを検出したタイミングのようですが、
Hot Rod Client自体はHibernate Searchには依存していないので、Infinispan Server内で動作していることになりますね。

というわけで、見た目はInfinispanオリジナルのインデックス用アノテーションなのですが、実際にはHibernate Searchの処理に変換されて
動作する、というのが裏舞台のようです。

また、アノテーションとは関係ないのですが、最初はインデックスの保存先を以下のように相対パスに指定するとどこに保存されるのかなと
思って試してみたのですが

                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("index/indexedBookCache")

結果は、[Infinispan Serverのインストールディレクトリ]/server/dataディレクトリ内の相対パスとして解釈されました。

どういう解決になっているのかな?と思って見てみました。

以下を見ると、パス指定が絶対パスの場合はそのまま使い、相対パスの場合はGlobalStateConfiguration#persistentLocationからの
相対パスとして解釈されるようです。

https://github.com/infinispan/infinispan/blob/14.0.3.Final/query/src/main/java/org/infinispan/query/impl/config/SearchPropertyExtractor.java#L157-L166

この設定を構築しているのはこちらのようですが、

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/java/org/infinispan/server/Server.java#L307

Inifnispan Serverのデフォルト設定を読み込んでいるのはこちら。

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/java/org/infinispan/server/Server.java#L298-L299

設定ファイルを見ると、${infinispan.server.data.path}が指す値がデフォルト値になっています。

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/resources/infinispan-defaults.xml#L12

その実際の値はというと、server/dataですね。

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/java/org/infinispan/server/Server.java#L254

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/java/org/infinispan/server/Server.java#L176

https://github.com/infinispan/infinispan/blob/14.0.3.Final/server/runtime/src/main/java/org/infinispan/server/Server.java#L179

インデックスを適当に試している時はメモリ上に保存していることが多かったので、今回ちょっと興味を持って見てみました。

まとめ

Infinispanの新しいインデックス用のアノテーションを試してみました。

実際のところはHibernate Searchの処理に読み替えられるので、特になにか新しい動作が加わったわけでもありません。なので、情報自体は
なかったのですがそれほど困らずに使えました。

Hibernate Searchとどういう関係にあるんだろう?と調べたりするところは、そこそこ時間がかかりましたけどね。

今回作成したソースコードは、こちらに置いています。

https://github.com/kazuhira-r/infinispan-getting-started/tree/master/remote-query-infinispan-annotation