Blog / Japanese

ClickHouseの新たな強力なJSONデータ型の開発プロセス

author avatar
Pavel Kruglov
Oct 22, 2024 - 7 minutes read

はじめに

JSONは現代のデータシステムで半構造化および非構造化データを扱うための共通言語となっています。ロギングやオブザーバビリティのシナリオ、リアルタイムデータストリーミング、モバイルアプリのストレージ、機械学習のパイプラインにおいても、JSONの柔軟な構造により、分散システム間でデータをキャプチャし送信するための共通のフォーマットとなっています。

ClickHouseでは、シームレスなJSONサポートの重要性を認識していました。しかし、JSONは単純なように見えても、大規模に効果的に活用するには独特の課題があり、それを以下に簡単に説明します。

課題 1: 真の列指向ストレージ

ClickHouseは 市場で最速分析データベースの一つです。そのようなパフォーマンスのレベルは、正しいデータの“オリエンテーション”でしか達成できません。ClickHouseは、テーブルをディスク上のカラムデータファイルのコレクションとして保存する真の 列指向データベースであり、これにより最適な 圧縮 やハードウェア効率の高い、高速なベクトル化列操作(フィルターや集計など)が可能になります。

JSONデータでも同じレベルのパフォーマンスを可能にするには、真の列指向ストレージをJSONに実装し、JSONパスが圧縮され、他の数値と同様に効率よく処理(フィルタリングやベクトル化された集計など)できるようにする必要がありました。

そのため、次の図に示すように、JSONドキュメントを文字列列にそのまま保存して(後でパースする)というような方法は避けましょう。

JSON-01.png

JSONパスの各ユニークな値を真の列指向の形式で保存することを目指しました:

JSON-02.png

課題 2: 型の統一なしに動的に変化するデータ

JSONパスを真の列指向の方法で保存できる場合、次の課題は、JSONが同じJSONパスに対して異なるデータ型の値を許可することです。ClickHouseの場合、これらの異なるデータ型は事前に知られていない場合があり、互換性がない可能性があります。さらに、最小の共通型に統一するのではなく、すべてのデータ型を保存する方法を見つける必要がありました。たとえば、同じJSONパスaに値として2つの整数と1つの浮動小数点数がある場合、次の図のようにすべてを浮動小数点数としてディスクに保存したくありません:

JSON-03.png

このようなアプローチは、混在する型のデータの整合性を保持せず、同じパスaの下に次に保存される値が配列であるような、より複雑なシナリオもサポートしないでしょう:

JSON-04.png

課題 3: ディスク上のカラムデータファイルの雪崩の防止

JSONパスを真の列指向の方法で保存することは、データ圧縮やベクトル化されたデータ処理の利点があります。しかし、多数のユニークなJSONキーが存在するシナリオでは、新しいユニークなJSONパスごとに新しいカラムファイルを作成すると、ディスク上のカラムファイルの雪崩状態に陥る可能性があります:

JSON-05.png

これは多くのファイルディスクリプター(それぞれがメモリにスペースを必要とする)を必要とし、多数のファイルを処理する必要があるため、マージのパフォーマンスに影響を与え、パフォーマンスの問題を引き起こす可能性があります。そのため、カラムの作成に制限を導入する必要がありました。これにより、JSONストレージを効果的にスケーリングし、ペタバイト規模のデータセットに対する高性能な分析を保証します。

課題 4: 密なストレージ

多数のユニークだがスパースなJSONキーが存在するシナリオでは、特定のJSONパスに実際の値がない行に対してNULLやデフォルト値を冗長に保存(および処理)するのを避けたいと考えました。以下の図で示すとおりです:

JSON-06.png

代わりに、各ユニークなJSONパスの値を密で非冗長な方法で保存したいと考えました。これにより、JSONストレージをPBデータセットに対する高性能な分析のためにスケーリングすることができます。

新たに大幅に強化されたJSONデータ型

伝統的な実装が直面するボトルネックなくJSONデータのハイパフォーマンスな処理を提供するために新たに開発された強化型JSONデータ型を発表します。

この初回の投稿では、この機能の開発にあたっての課題(および過去の制限)に対応しながら、なぜ私たちの実装が列指向ストレージの上に構築されたJSONの最高の実装であるかをお見せします。次にサポートする機能を提供します:

  • 動的に変化するデータ: 同じJSONパスに対して異なるデータ型(時には互換性がなく、事前に知られていない)の値を許可し、最小の共通型に統一することなく混合型データの整合性を維持します。

  • 高性能かつ密で真の列指向ストレージ: 挿入された任意のJSONキーのパスをネイティブで密なサブカラムとして保存・読み込みし、高いデータ圧縮を可能にし、従来の型で見られるクエリ性能を維持します。

  • スケーラビリティ: 保存されるサブカラムの数を制限することで、PBデータセットに対する高性能な分析のためにJSONストレージをスケーリングします。

  • チューニング: JSON解析のヒント(JSONパスのための明示的な型、解析中にスキップすべきパスなど)を提供します。

この投稿の残りの部分では、JSONを超えた広範なアプリケーションを持つ基礎的なコンポーネントを最初に構築することにより、新しいJSON型の開発について説明します。

ビルディングブロック 1 - Variant型

Variantデータ型は、新しいJSONデータ型を実装するための最初の構成要素です。この型はJSONの外でも使用できる完全に独立した機能として設計され、同じテーブルカラム内で異なるデータ型の値を効率的に保存(および読み取り)できます。最小の共通型に統一することはありません。これで最初のおよび2つ目の課題を解決します。

ClickHouseにおける従来のデータ保存

新しいVariantデータ型がない場合、ClickHouseテーブルのカラムはすべて固定型であり、挿入されるすべての値は対象のカラムの正しいデータ型であるか、必要な型に暗黙的に強制されなければなりません。

Variant型の動作をよりよく理解するための準備として、以下の図は固定データ型のカラムを持つMergeTreeファミリのテーブルが、ディスク上でどのようにデータを保存するか(データパートごとに)を示しています:

JSON-07.png

上記の図で例示されたテーブルを再現するためのSQLコードはこちらです。各カラムにはデータ型が注釈として付けられていることに注意してください。例えば、カラムC1Int64型です。ClickHouseは列指向データベースであるため、各テーブルカラムの値はディスク上で別々に(高度に圧縮された)カラムファイルに保存されます。カラムC2Nullableであるため、ClickHouseはNULLマスクを保持する別のファイルを使用し、通常のカラムファイルに加えてNULLと空(デフォルト)値を区別するために用います。テーブルカラムC3については、ClickHouseがどのように配列を保存することをネイティブにサポートしているかを示しており、各テーブル行の配列のサイズをディスク上の別のファイルで保存しています。これらのサイズ値は、データファイル内の配列要素にアクセスするための対応するオフセットを計算するために使用されます。

動的に変化するデータのためのストレージ拡張

新しいVariantデータ型を使用すると、上記のテーブルのすべてのカラムからのすべての値を単一のカラムに保存できます。次の図は、クリックして拡大すると、そのカラムがどのように機能し、ClickHouseの列指向ストレージを基にディスク上でどのように実装されているか(データパートごとに)をスケッチしています:

Markdown Image

図に示す例のテーブルを再現するためのSQLコードはこちらです。この例では、ClickHouseのテーブル列Cに対してVariant型を指定しました。これにより、列Cに整数、文字列、整数の配列を混在して格納できるようになります。このようなカラムに対して、ClickHouseは同じデータ型のすべての値を別々のサブカラムに保存します(Variant型のカラムデータファイルで、それ自体は前の例のカラムデータファイルとほぼ同一に見えます)。例えば、すべての整数値はC.Int64
.binファイルに保存され、すべての文字列値はC.String
.binに保存され、そして他の型も同様です。

サブタイプ間の切り替えのための識別子カラム

ClickHouseテーブルの各行で使用されているタイプを知るために、ClickHouseは各データタイプに識別子を割り当て、これらの識別子を含む追加の(UInt8)カラムデータファイル(上図のC.variant_discr.bin)を保存します。各discriminator値は、ソートされた使用タイプ名のリストへのインデックスを表します。discriminator 255はNULL値用に予約されており、これは設計上、Variantが最大255の異なる具体的なタイプを持つことができることを意味します。

特に注意すべきは、NULLマスクファイルを別途持つ必要がなく、NULLとデフォルトの値を区別することです。

さらに、識別子のコンパクトなシリアル化形式が存在し、(典型的なJSONシナリオに最適化するために)特別な形式が用意されています。

密なデータストレージ

Variantタイプのカラムデータファイルは密です。これらのファイルにNULL値を保存しません。多くのユニークだがスパースなJSONキーが存在するシナリオでは、特定のJSONパスに対して実際の値がない行に対してデフォルト値を保存しません(この図で示されている反例として)。これで、第4の課題を解決します。

この密なVariant型のストレージのため、識別子カラムの行を対応するVariant型のカラムデータファイルの行にマッピングする必要もあります。この目的のために、UInt64オフセットカラム(上図のoffsets)が存在し、メモリ上にしか存在しないが(識別子カラムファイルから動的に作成可能)、ディスクには保存されません。

例えば、上図のClickHouseテーブルの行6の値を取得するために、ClickHouseは識別子カラムの行6を検査して要求された値を含むVariant型カラムデータファイルC.Int64
.binを特定します。さらに、ClickHouseはoffsetsファイルの行6を調べて、C.Int64
.binファイル内の要求された値の具体的なoffsetsを知っています。そのため、ClickHouseテーブルの行6の要求値は44です。

Variant型の任意のネスト

Variantカラム内にネストされる型の順序は関係ありません: Variant(T1, T2) = Variant(T2, T1)です。さらに、Variant型は任意のネストを許可し、Variant内で使われるVariant型の1つとしてVariant型を使用できます。別の図を用いて(クリックして拡大可能)、これを示します:

Markdown Image

この図に示された例のテーブルを複製するためのSQLコードはこちらです。今回は、VariantカラムCを使用して、整数、文字列、Variant値を含む配列のミックスを格納することを指定しました。上の図は、ClickHouseが内部で、上記で説明したVariantストレージのアプローチをどのように配列カラムデータファイル内にネストして、ネストされたVariant型を実現しているかをスケッチしています。

サブカラムとしてのVariantネスト型の読み取り

Variant型は、サブカラムとして型名を使用してVariantカラムから単一のネスト型の値を読み取ることをサポートします。たとえば、上記のテーブルのCサブカラムのInt64型のすべての整数値をC.Int64という構文を使用して読み取ることができます:

SELECT C.Int64
FROM test;

   ┌─C.Int64─┐
1.422. │    ᴺᵁᴸᴸ │
3. │    ᴺᵁᴸᴸ │
4.435. │    ᴺᵁᴸᴸ │
6. │    ᴺᵁᴸᴸ │
7.448. │    ᴺᵁᴸᴸ │
9. │    ᴺᵁᴸᴸ │
   └─────────┘

ビルディングブロック 2 - Dynamic型

Variant型の後には、Dynamic型の実装が続きます。このDynamic型は、JSONコンテキストの外でも単独で使用できる独自の特長を持つ機能として実装されています。

Dynamic型はVariant型の強化版と見なされ、2つの重要な新機能を導入します:

  1. 単一のテーブルカラム内に任意のデータ型の値を保存し、すべての型を事前に知って指定する必要はありません。

  2. サブカラムデータファイルとして保存される型の数を制限する可能性。これにより、部分的に3番目の課題であるディスク上のカラムデータファイルの雪崩現象を解決します。

次にこれら2つの新機能について簡単に説明します。

サブタイプを指定する必要はありません

次の図は、クリックして拡大可能で、単一のDynamicカラムを持つClickHouseテーブルとそのディスク上の保存方法(データパートごとに)を示しています:

Markdown Image

この図に示されるテーブルを再現するためのSQLコードはこちらです。DynamicカラムCには、Variant型で行うように、事前に型を指定せずに任意の型の値を挿入できます。

内部的に、DynamicカラムはVariantカラムと同様にディスク上にデータを保存し、特に構造に関する追加情報を保持します。図は、保存方法の差異を示しており、Dynamicカラムがサブカラムと保存するためのC.dynamic_structure.binという追加のファイルを持ち、保存された型のリストとそのVariant型カラムデータファイルのサイズの統計を含んでいることを示しています。このメタデータは、サブカラムの読み取りとデータパートのマージに使用されます。

カラムファイルの雪崩を防ぐ

Dynamic型は、型定義でmax_typesパラメータを指定することで、サブカラムデータファイルとして保存される型の数を制限することもサポートしています: Dynamic(max_types=N)ここで0<= N <255。max_typesのデフォルト値は32です。この制限が達成されると、残りのすべての型は特別な構造を持つ単一のカラムデータファイルに保存されます。次の図でその例を示しています(クリックして拡大可能):

Markdown Image

上記の図に示された例のテーブルを生成するためのSQLスクリプトはこちらです。今回は、max_typesパラメータを3に設定したDynamicカラムCを使用します。

したがって、最初の3つの使用タイプのみが個別のカラムデータファイルに保存されます(これは圧縮と分析クエリに効率的です)。さらに使用される追加のタイプ(上の例のテーブルで緑色でハイライトされている部分)からのすべての値は、Stringタイプを持つ単一のカラムデータファイル(C.SharedVariant.bin)にまとめて保存されます。SharedVariantの各行には、以下のデータを含む文字列値が含まれています:<binary_encoded_data_type><binary_value>。この構造を使用することで、単一のカラム内に異なるタイプの値を保存(および取得)することができます。

サブカラムとしてダイナミックネスト型の読み取り

Variant型と同様に、Dynamic型はサポートしており、Dynamicカラムから特定のネストされた型の値をサブカラムとして読み取ることができます。型名を使用します:

SELECT C.Int64
FROM test;

   ┌─C.Int64─┐
1.422. │    ᴺᵁᴸᴸ │
3. │    ᴺᵁᴸᴸ │
4.435. │    ᴺᵁᴸᴸ │
6. │    ᴺᵁᴸᴸ │
7.448. │    ᴺᵁᴸᴸ │
9. │    ᴺᵁᴸᴸ │
   └─────────┘

ClickHouse JSON型: すべてをひとつにまとめる

VariantおよびDynamic型の実装後、ClickHouseの列指向ストレージ上に新たな強力なJSON型を実装するために必要なすべての構成要素が揃い、課題を克服するためのサポートが整いました:

  • 動的に変化するデータ: 同じJSONパスに対して異なるデータ型(時には互換性がない場合や事前に判明しない場合もある)の値を許可し、型の統一なしに混合型データの整合性を保ちます。

  • 高性能で密度が高い、真の列指向ストレージ: ネイティブで密度のあるサブカラムとして挿入されたJSONキーを保存および読み取り、高いデータ圧縮と従来の型で見られたクエリ性能を維持します。

  • スケーラビリティ: 個別に保存されるサブカラムの数を制限することができ、PBデータセットに対する高性能分析のためのJSONストレージをスケールします。

  • チューニング: JSON解析のヒント(JSONパスの明示的な型、解析時にスキップされるべきパスなど)を許可します。

新しいJSON型は、任意の構造を持つJSONオブジェクトの保存を可能にし、JSONパスをサブカラムとして使用してその中のすべてのJSON値を読み取ることができます。

JSON型の宣言

新しい型には、いくつかのオプションパラメータとヒントが宣言に含まれています:

<column_name> JSON(
  max_dynamic_paths=N, 
  max_dynamic_types=M, 
  some.path TypeName, 
  SKIP path.to.skip, 
  SKIP REGEXP 'paths_regexp')

ここで:

  • max_dynamic_paths(デフォルト値1024)は、サブカラムとして個別に保存されるJSONキーパスの数を指定します。この制限を超えた場合、他のすべてのパスは特殊な構造を持つ単一のサブカラムにまとめて保存されます。

  • max_dynamic_types(デフォルト値32)は0から254の間の値で、Dynamic型を持つ単一のJSONキーパスカラムに対して、個別のカラムデータファイルとして保存される異なるデータタイプの数を指定します。この制限を超えた場合、新しいタイプはすべて特殊な構造を持つ単一のカラムデータファイルにまとめて保存されます。

  • some.path TypeNameは特定のJSONパスに対するタイプヒントです。このようなパスは常に指定された型のサブカラムとして保存され、パフォーマンスが保証されます。

  • SKIP path.to.skipはJSON解析中にスキップすべき特定のJSONパスに対するヒントです。このようなパスはJSONカラムに保存されることはありません。指定されたパスがネストされたJSONオブジェクトの場合、ネストされたオブジェクト全体がスキップされます。

  • SKIP REGEXP 'path_regexpはJSON解析中にパスをスキップするために使用される正規表現を含むヒントです。この正規表現にマッチするすべてのパスはJSONカラムに保存されることはありません。

真の列指向JSONストレージ

以下の図(クリックすると拡大表示できます)は、単一のJSONカラムを持つClickHouseテーブルとそのカラムのJSONデータがClickHouseの列指向ストレージ上でどのように効率的に実装されているかを示しています(各データパートあたり):

Markdown Image

以下のSQLコードを使用して、上の図で示されるようにテーブルを再作成します。我々の例のテーブルのカラムCJSON型で、JSONパスa.ba.cの型を指定する2つの型ヒントを提供しました。

私たちのテーブルカラムには6つのJSONドキュメントが含まれており、それぞれのユニークなJSONキーのパスの葉の値は、通常のカラムデータファイルとして(型付きJSONパス、型ヒント付きパスの場合、図のC.a.bC.a.cを参照)または動的サブカラムとして(動的JSONパス、データが動的に変化する可能性のあるパスの場合、図のC.a.dC.a.d.eC.a.eを参照)、ディスクに保存されます。後者の場合、ClickHouseは動的データ型を使用します。

加えて、JSON型は動的パスに関するメタデータ情報や各動的パスの非NULL値の統計(カラムシリアル化時に計算される)を含む特別なファイル(object_structure)を使用しています。このメタデータはサブカラムの読み取りとデータパーツのマージに使用されます。

カラムファイルの雪崩を防ぐ

1つのJSONキーのパス内で動的型が多く存在するシナリオや、動的JSONキーのユニークなパスが大量に存在するシナリオでディスク上に多くのカラムファイルが爆発的に増加するのを防ぐために、JSON型は以下を許可しています:

(1) 単一のJSONキーのパスに対してどれだけ多くの異なるデータ型が個別のカラムデータファイルとして保存されるかをmax_dynamic_types(デフォルト値32)パラメータで制限する。

(2) JSONキーのパスがサブカラムとして個別に保存される数をmax_dynamic_paths(デフォルト値1024)パラメータで制限する。

これが第三の課題を解決するものです。

(1)の例は上記に示されています。そして、(2)については、他の図を使用して示します(クリックすると拡大表示できます):

Markdown Image

この図のテーブルを再現するためのSQLコードはこちらです。前の例と同様に、私たちのClickHouseテーブルのカラムCJSON型で、JSONパスa.ba.cの型を指定する同じ2つの型ヒントを提供しました。

さらに、max_dynamic_pathsパラメータを3に設定しました。これにより、ClickHouseは最初の3つの動的JSONパスの葉の値のみを動的サブカラムとして保存します(Dynamic型を使用する)。

追加の動的JSONパスは、それらの型情報と値(上の例のテーブルで緑色でハイライトされている部分)がすべて共有データとして保存されます - 上図のC.object_shared_data.size0.binC.object_shared_data.paths.binC.object_shared_data.values.binファイルを参照してください。共有データファイル(object_shared_data.values)はString型であることに注意してください。各エントリは、以下のデータを含む文字列値です:<binary_encoded_data_type><binary_value>。

共有データと共に、サブカラムの読み取りやデータパーツのマージに使用される追加の統計情報をobject_structure.binファイルに保存します。共有データカラムに保存されている(現在のところ最初の10000の)パスの非NULL値に関する統計を保存しています。

JSONパスの読み取り

JSON型は、パス名をサブカラムとして使用して、各パスのリーフ値を読み取ることをサポートしています。たとえば、上記の例でJSONパスa.bのすべての値をC.a.bという文法で読み取れます:

SELECT C.a.b
FROM test;

   ┌─C.a.b─┐
1.102.203.304.405.506.60 │
   └───────┘

要求されたパスの型がJSON型の宣言で型ヒントによって指定されていない場合、そのパスの値は常にDynamic型を持ちます:

SELECT
    C.a.d,
    toTypeName(C.a.d)
FROM test;

   ┌─C.a.d───┬─toTypeName(C.a.d)─┐
1.42Dynamic2.43Dynamic3. │ ᴺᵁᴸᴸ    │ Dynamic4. │ foo     │ Dynamic5. │ [23,24] │ Dynamic6. │ ᴺᵁᴸᴸ    │ Dynamic           │
   └─────────┴───────────────────┘

また、特別なJSON構文JSON_column.some.path.:TypeNameを使用してDynamic型のサブカラムを読み取ることも可能です:

SELECT C.a.d.:Int64
FROM test;


   ┌─C.a.d.:`Int64`─┐
1.422.433. │           ᴺᵁᴸᴸ │
4. │           ᴺᵁᴸᴸ │
5. │           ᴺᵁᴸᴸ │
6. │           ᴺᵁᴸᴸ │
   └────────────────┘

さらに、JSON型はサポートしており、特別な構文JSON_column.^some.pathを使用して、JSON型でネストされたJSONオブジェクトをサブカラムとして読み取ることができます:

SELECT C.^a
FROM test;

┌─C.^`a`───────────────────────────────────────┐
1. │ {"b":10,"c":"str1","d":"42"}                 │
2. │ {"b":20,"c":"str2","d":"43"}                 │
3. │ {"b":30,"c":"str3","e":"44"}                 │
4. │ {"b":40,"c":"str4","d":"foo","e":"baz"}      │
5. │ {"b":50,"c":"str5","d":["23","24"]}          │
6. │ {"b":60,"c":"str6","d":{"e":"bar"},"e":"45"} │
└──────────────────────────────────────────────┘
SELECT toTypeName(C.^a)
FROM test
LIMIT 1;

   ┌─toTypeName(C.^`a`)───────┐
1.JSON(b UInt32, c String) │
   └──────────────────────────┘

現時点では、ドット構文はパフォーマンス上の理由でネストされたオブジェクトを読み取りません。データはパスごとにリテラル値を非常に効率的に読み取れるように保存されていますが、パスごとにすべてのサブオブジェクトを読み取るには、より多くのデータを読み込む必要があり、時には遅くなることもあります。したがって、オブジェクトを返したい場合には、代わりに.^を使用する必要があります。現在、2つの異なる.構文を統一する計画をしています。

もう一つの詳細 - コンパクトなディスクリミネータのシリアル化

多くのシナリオでは、動的なJSONパスはほとんど同じ型の値を持つことがあります。この場合、Dynamic型のディスクリミネータファイルには主に同じ数(型ディスクリミネータ)が含まれることになります。

同様に、多くのユニークでスパースなJSONパスを保存する場合、それぞれのパスのディスクリミネータファイルには主に値255(NULL値を示す)が含まれることになります。

両方の場合においてディスクリミネータファイルは十分に圧縮されますが、すべての行が同じ値を持つ場合にはかなり冗長になる可能性があります。

これを最適化するために、ディスクリミネータのシリアル化の特別なコンパクト形式を実装しました。通常のUInt8値としてディスクリミネータを記述する代わりに、ターゲットグラニュール内ですべてのディスクリミネータが同じ場合、3つの値のみをシリアル化します (8192 値の代わりに):

  1. コンパクトグラニュールフォーマットのインジケータ
  2. このグラニュール内の値の数のインジケータ
  3. ディスクリミネータ値

この最適化は、MergeTreeの設定use_compact_variant_discriminators_serialization(デフォルトで有効)によって制御できます。

ここからが始まりです

この記事では、JSONの基礎的な構成要素を最初に作成することで、私たちの新しいJSON型をゼロからどのように開発したかを概説しました。

この新しいJSON型は、現在は非推奨となったObject('json')データ型を置き換えることを目的として設計されており、その制限を克服し、全体的な機能性を改善しています。

新しい実装は現在、テスト目的でリリースされており、機能セットはまだ完成していません。私たちのJSONロードマップには、テーブルの主キーやデータスキッピングインデックス内でJSONキーパスを使用するなど、いくつかの強力な機能拡張が含まれています。

また、新しいJSONタイプを実装するために作成した基盤ブロックは、ClickHouseがXML、YAML、その他の半構造化タイプをサポートするための道を開きました。

今後の投稿では、実際のデータを使用して新しいJSONタイプの主要なクエリ機能を紹介し、データ圧縮とクエリパフォーマンスのベンチマークを示します。また、JSONの実装の内部動作についても詳しく説明し、データがメモリ内でどのように効率的にマージされ処理されるかを解説します。

ClickHouse Cloudを使用していて、新しいJSONデータタイプをテストしたい場合は、プライベートプレビューを有効にするためにサポートにご連絡ください。

Share this post

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!
Loading form...
Follow us
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image