- Introduction
- gRPC
- TypeDB Protocol
- Client Code Architecture
- Tutorial: Create a Database
- Session and Transaction
- Inside a Transaction Stream
- Exploring Query Answers: Concept API
- TypeDB Cluster Client
- Rapid Testing with BDD
- Conclusion
Introduction
Vaticle maintains official client drivers for Java, Python, and NodeJS. All our other client drivers are community-built.
It’s possible to build a client for any language! A TypeDB client fundamentally is a lightweight frontend to the TypeDB server. This page is a guide for the components and protocols that need to be implemented.
gRPC
gRPC is the network call framework that TypeDB uses. A TypeDB client needs a gRPC client library to communicate with the server. Most languages have gRPC libraries.
Why use gRPC?
Architecturally, gRPC is an alternative to HTTP (say, REST API or websockets). In TypeDB’s client-server architecture, performance is critical, and gRPC fits well with TypeDB’s scaling model. It establishes a long-lasting connection, much like a websocket. Payloads are encoded in the Protocol Buffer format, which is both efficient and strongly typed:
// Example message from typedb-protocol: note that each field has a restricted data type (string, int64 etc.)
message Attribute {
message Value {
oneof value {
string string = 1;
bool boolean = 2;
int64 long = 3;
double double = 4;
// time since epoch in milliseconds
int64 date_time = 5;
}
}
}
TypeDB Protocol
Protocol Buffers is the encoding used to serialise network messages. Proto definition files can be compiled into server-side and client-side libraries using a Protobuf Compiler. In our case, we only need client-side library compilation. Most languages have Protobuf compilers available.
TypeDB’s protobuf definitions can be found at https://github.com/vaticle/typedb-protocol. During development, it’s sufficient to manually copy-paste from this repository and do a one-time compilation. A more reliable method is to import the Protocol repo via a package manager and compile it at build time. TypeDB’s build system, Bazel, offers one approach. If you’d like to use a different package manager, the TypeDB team may also be able to help by setting up a distribution channel for your language’s compiled protobuf files. If this is the case, please get in touch.
Client Code Architecture
TypeDB’s official client drivers adhere to a common architecture. This greatly reduces the workload of maintaining them, so we also recommend community contributions to follow the same basic structure.
This diagram shows all the packages (directories) in Client Java and their dependency graph:
The entry point is the root package, in this case named client-java
.
api
is where we declare all the available client methods – basically all the interfaces.
core
holds the basic building blocks: client, session, transaction.
Then we have query
for querying, concept
for Concept API, logic
for reasoning.
There are many places you could start building a client. In this guide, we start by attempting to make a single gRPC call to TypeDB, and create a database.
Tutorial: Create a Database
Create a TypeDB
source file in the root of the project, which should expose a function named coreClient
, taking address
as a parameter.
Import statements are not included in this tutorial, except when importing from external libraries such as the TypeDB protobuf definitions.
TypeDBClient
is not yet defined. Create a new directory named api/connection
and create a TypeDBClient
file there:
(Note: if your language doesn’t have interfaces or abstract classes, make TypeDB.coreClient
return CoreClient
instead, and skip this step)
The next step is to implement connection/TypeDBClient
and its subclass connection/core/CoreClient
.
Create the directory structure: connection/core
in the root of your project.
Name the classes depending on language conventions: in Java/TypeScript, TypeDBClientImpl
and CoreClient
; in Python, _TypeDBClient
and _CoreClient
.
Ensure that you’ve imported gRPC into your project, and refer to the gRPC docs to learn how to create a Channel - the code varies by language.
(Note: In languages with no inheritance, adhere to this project structure as closely as possible, perhaps by writing top-level functions in the respective locations)
Finally, we implement DatabaseManager
, and CoreStub
to set up gRPC calls to the server.
You’ll need to compile TypeDB’s protocol in order to do this. Most languages have protobuf compilers that you can use to generate a TypeDB protocol library for your language.
At this point, we have all the necessary components to create a database! Run the TypeDB server locally and create a test function:
We can verify that the database was created successfully using Console’s database list
command, or by rerunning the test (which will throw an error saying that the database already exists).
That concludes the basics tutorial. The following sections give an overview of the remaining components needed to open transactions, run queries, and take the client to 100% completion.
We recommend using one of our existing Clients as a reference, and copying the implementation into your chosen language.
Session and Transaction
To query schema and data, we need to open a Session and Transaction of the appropriate types. For example, you can’t modify schema in a data session.
A Session is essentially a long-lasting tunnel from client to database. However, we implement that with just simple RPC calls - Open and Close.
Sessions consume server resources, and may hold locks. If a client disconnects (say, by crashing) the server needs a way to know. So, we use a pulse mechanism. Every 5 seconds, a TypeDB client sends a Session Pulse to inform the server that the client is still alive. If no pulse is received in 30 seconds, the server times out the session, freeing up its resources for use elsewhere.
Once a Session is open, we can open a Transaction inside it to read and write to the database. This is implemented with a bidirectional streaming RPC. Rather like a websocket, it’s a long-lasting tunnel that allows the client and server to talk to each other.
TypeDB clients support multiple layers of concurrency. A Client can have many Sessions, and a Session can have many Transactions, and a Transaction can perform many Queries.
Inside a Transaction Stream
Inside a transaction stream, the client sends requests, and the server is expected to respond to the client’s requests in a timely manner.
Each request must have the same message type. This is Transaction.Client
, defined in typedb-protocol:
// typedb-protocol/common/transaction.proto
message Transaction {
message Client {
repeated Req reqs = 1;
}
message Req {
bytes req_id = 1;
map<string, string> metadata = 2;
oneof req {
Open.Req open_req = 3;
Stream.Req stream_req = 4;
Commit.Req commit_req = 5;
Rollback.Req rollback_req = 6;
QueryManager.Req query_manager_req = 7;
ConceptManager.Req concept_manager_req = 8;
LogicManager.Req logic_manager_req = 9;
Rule.Req rule_req = 10;
typedb.protocol.Type.Req type_req = 11;
Thing.Req thing_req = 12;
}
}
}
Each request message is suffixed with .Req
, and has a matching .Res
(or .ResPart
) to represent the server’s response to that message.
Now, there are two basic patterns to the communications; single responses and streamed responses, both of which are illustrated below.
(Here, Define.Req
and Match.Req
are both types of QueryManager.Req
, and Type.Create.Req
and GetThing.Req
are types of ConceptManager.Req
)
Handling Streamed Responses
For requests such as TypeQL Match queries, the responses can be very long, so TypeDB breaks them up into parts.
We issue Match.Req
, and get back multiple Match.ResPart
s, which each contain some answers to the query.
Getting all the answers may be costly in terms of server resources, and it can be wasteful if the client exits early.
So we only auto-stream up to a certain limit, called the prefetch size, then we send a special message called “Continue”.
If the client needs more answers, it should respond with a Stream.Req
.
That tells the server to continue streaming, and, when there are no answers left, it sends a Stream.ResPart
with state = DONE
.
In a client, the Match response is typically represented as a Stream or Iterator. Seeing “DONE” from the server signals the end of iteration. The iterator implementation varies a bit by language. In Java, Streams are in-built; in Python we use an Iterator, and in NodeJS we use an Async Iterator. Use whatever is most natural in your language.
Handling Concurrent Requests
Concurrent queries create a slight complication, since all the responses go down the same gRPC stream. We handle them
by attaching a Request ID (req_id
) to each request, and, whenever a Request is made, we create a Response Collector – essentially a bucket, or queue, that holds responses for this Query.
The queue fills up as answers are received from the server, and it gets emptied as the user iterates over these answers.
Request Batching
Loading bulk data may potentially require millions of INSERT queries, and gRPC can only send so many in a given timeframe.
To mitigate this, we use request batching - see the RequestTransmitter
class in any official client.
It collects all requests in a 1ms time window, bundles them into a single gRPC message, and dispatches it.
Exploring Query Answers: Concept API
The ConceptMap
objects returned by a TypeQL Match query can contain any type of Concept
. This Concept
class hierarchy is reflected in TypeDB’s client implementation and class structure.
Implementing Concept API is not complicated, but it is quite long as there are a lot of methods. Concept methods either return single or streamed responses. ThingType.getInstances
is an example of a Streamed Concept method.
TypeDB Cluster Client
TypeDB Cluster runs as a distributed network of database servers which communicate internally to form a consensus when querying. If one server has an outage, we can recover from the issue by falling back to another server. To enable this, a Cluster client constructs 1 Core client per Cluster node:
Suppose we open a Transaction to, say, Node 1, but we don’t get a response.
In TypeDB, that would be a non-recoverable error. In Cluster, the Cluster client simply reroutes the request to a different Core client, which sends the request to its linked server. In this way, the client recovers from the failure and continues running as normal.
Rapid Testing with BDD
The recommended way to test a TypeDB Client is by using the TypeDB Behaviour spec. It’s written in a language-agnostic syntax named Gherkin. Tests consist of named steps. To run the tests in a new client, you just need to implement the steps. This means you can test your client without having to write a single test!
# To run the test, implement each step: e.g. "connection create database: {name}"
Scenario: commit in a read transaction throws
When connection create database: typedb
Given connection open schema session for database: typedb
When session opens transaction of type: read
Then transaction commits; throws exception
Conclusion
A client is considered production-ready once it passes all the tests and adheres to the TypeDB architecture. If you encounter any difficulties along the way, do get in touch with the Vaticle team, preferably on Discord - we’re happy to help speed up the development process. This will also enable us to add your project into the TypeDB Open Source Initiative.