quinchs / EdgeDB.Net

C# edgedb client

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

API design and consistancy

quinchs opened this issue · comments

Summary

The API design for creating, managing, and using clients differ from other EdgeDB drivers. The goal is to make a driver that is easy to switch to from other drivers.

Current Design

The current clients follow an abstraction design pattern to allow for user-based abstraction as well as easy drop-in client types for pooling.

Client hierarchy:

image

Note

The client pool does not have direct access to the type of client its using, its sole job is to manage the pool of clients based on the configuration.

API

Terminology

  • Client Pool: A pool of clients that can be used to communicate with the database.
  • Client Instance: A single connection to the database, whether that be tcp, http, etc.

Functionality

The client pool (EdgeDBClient class) is not lazy in how it instantiates clients, clients Connect method will be called when the user requests a client.

Example

var client = new EdgeDBClient(...);

await using var rawClient = await client.GetOrCreateClientAsync();

Method logic

edgedb_client drawio

Pooled client lifetime

A client instance returned from a client pool will be added back into the client pool apon the disposal of the client instance class. This is done by the IDisposable interface.

await using (var rawClient = await client.GetOrCreateClientAsync())
{
  // preform operations with the client instance
}

// 'rawClient' falls out of scope and the 'using' statement calls the 'DisposeAsync' method, this returns the client instance back to the client pool

Proposed design

The proposed design for the client pool is as follows:

  • Clients are lazily instantiated, when the user requests a client instance, that instance is only connected apon the first query operation.
  • GetOrCreateClientAsync will be renamed to CreateClient
  • Client instances connection methods are blocked by a client pool. If the pool is full, the connect method will wait until a free spot is available.
  • Pooling logic is done at the query step, since the client is connected at that step as well.

Example

using var rawClient = client.CreateClient(); // create a client instance
var result = await rawClient.QueryAsync(...); // 'QueryAsync' will wait for the pool to allow connection, connect, and then query

Caveats

  • User can instantiate multiple client instance classes which can lead to poor memory usage compared to current design.
  • Client instance classes have a short lifetime and arn't resused.
  • Users don't implicitly know that the execute methods of a client also preform pooling and connection operations.

cc @colinhacks @1st1

I am quite conflicted about having the query be responsible for connection logic in terms of does it concern the query?
While being lazy with the actual connection might have its benefits and while I also understand the urge to be as close to reference implementations in other languages, I am quite unsure if not reusing clients, opening the design up to potential missuse by inexperienced users and trying to fit other language idioms in C# is a good idea.

@Syzuna

I am quite conflicted about having the query be responsible for connection logic in terms of does it concern the query?

If a user wants to ensure that the connection is made they can use the explicit await client.EnsureConnected() method.

While being lazy with the actual connection might have its benefits and while I also understand the urge to be as close to reference implementations in other languages, I am quite unsure if not reusing clients, opening the design up to potential missuse by inexperienced users and trying to fit other language idioms in C# is a good idea.

FWIW the design we implement for our client libraries is innovative for all of the languages we implemented support for so far. We're not trying to bring some existing design patterns from Python or JS to the .NET world, we're designing our APIs to make sense.

Our client implementations must have a branch-off functionality, when you derive client objects from existing ones with some modifications of the state, e.g. client2 = client.WithGlobals(...) and this derivation should be a sync operation.

EdgeDB clients also support automatic network reconnect. Even if a client loses the underlying socket connection it will re-establish it when needed. So a reconnect can happen during any API call. This makes an explicit await createClientAndConnect() API unnecessary.

@quinchs

Caveats

User can instantiate multiple client instance classes which can lead to poor memory usage compared to current design.

How is this different for the old design?

Client instance classes have a short lifetime and arn't resused.

Why can't they be reused? Can't the CreateClient() call be at the entry point level of you program and then used throughout?

Users don't implicitly know that the execute methods of a client also preform pooling and connection operations.

As I explained in #20 (comment) -- there's no way around here. We want the client API to transparently and automatically reconnect on network errors, so the query methods have to know how to reconnect and be able to do so implicitly (within what's allowed by the retry policy of the current client; see the API in Python/JS clients for that)

How is this different for the old design?

Client instances are disposable, meaning we can free up memory be de-alloc'ing properties of the class and the garbage collector will free them after they fall out of scope.

Why can't they be reused? Can't the CreateClient() call be at the entry point level of you program and then used throughout?

Client instances can be cached for reuse purposes but if we do that I feel like the name should be GetOrCreateClient as you're either getting a reference to a cached instance or creating one.

We want the client API to transparently and automatically reconnect on network errors, so the query methods have to know how to reconnect and be able to do so implicitly

This makes a lot of sense, I agree with that.

One thing to note which I forgot to mention is to preform queries with the client pool, you do not need a reference to an instance. The client pool contains the QueryXYZ methods which will get or create a client and preform the operation of them like so:

public async Task<IReadOnlyCollection<TResult?>> QueryAsync<TResult>(string query, IDictionary<string, object?>? args = null,
    Capabilities? capabilities = Capabilities.Modifications, CancellationToken token = default)
{
    if (!_isInitialized)
        await InitializeAsync(token).ConfigureAwait(false);

    await using var client = await GetOrCreateClientAsync(token).ConfigureAwait(false);
    return await client.QueryAsync<TResult>(query, args, capabilities, token).ConfigureAwait(false);
}