Elixir-dbvisor/sql Needs a SOTA pool and I want to hear your ideas

So the day has come, and due to unforeseen technical issues, then SQL - Brings an extensible SQL parser and sigil to Elixir, confidently write SQL with automatic parameterized queries. - Libraries - Elixir Programming Language Forum will require it’s own pool, we want to aim high, so we’re talking SOTA. I want to hear your ideas, but lets talk requirements so we know what we’re working with.

  • Minimal

  • Ideally no message passing, one or two is not a deal breaker

  • Affinity for prepared queries and scheduler id, all queries have a global unique integer set at compile time

  • Linear

  • Dynamic sizing

1 Like

Some thoughts.

  • In order to “warm up” the db connections it is best to quickly re-use connections, instead of round-robin them.
  • Make it possible to cancel long-running queries
  • Check that the workers/connections are not shared between processes
  • It would be nice to have a “pauze” function where you pause workers when you are doing schema maintenance.
  • Ability to have different pools (for multi-tenant)
  • Stats.. #worker-checkout/min, pool usage, etc
1 Like

I hope the pool library would be written in Erlang so it is easily usable from both Erlang and Elixir!

We’ve also had a real production need for a pool that has a max lifetime on connections (the underlying reason being having to go through firewalls that has a max connection lifetime of e.g. 1 hour, so we would frequently get errors when checking out a stake connection that was silently dead on the TCP level). I don’t know of any Erlang pool library that supports this.

1 Like
  • When you talking warm-up are you referring to conns that has already prepared a query?
  • I don’t believe it’s the pools job to cancel anything, the caller should handle that.
  • This is essential, but not the pools responsibility, a data structure should be used to flag rather a connection is in use or not.
  • This is interesting, why would you want the pool to be responsible for this, I imagine that you would want to leverage the database to lock the tables as there could be clients that connect outside of your application.
  • I don’t believe the pool in it self should be concerned with multi tenancy, as user should be able to create as many independent pools as they see fit.
  • Yes, stats are important, especially for dynamic sizing.

I hope the pool library would be written in Erlang so it is easily usable from both Erlang and Elixir!

Unfortunately, this pool will not be it’s own library to begin with, also it will be in Elixir. I do plan to port most if the code to Erlang but currently there are some issues with some macros we use for unicode set identification which is preventing us from porting must of the code to Erlang.

No, database connections internally cache information from previous queries. Reusing that cache is what I meant. BTW, that may also be a reason to drop the connection after a period, because it could lead to excessive memory use.

For when you do a live upgrade of your schema. This way you can temporarily let your callers wait until the update is done. We generally don’t take external clients into account.

You probably don’t want to return a connection to the pool with a running query in it.

Correct, a connection shouldn’t be claimable before a caller has released it and no queries should be running when it’s checkin.

For when you do a live upgrade of your schema. This way you can temporarily let your callers wait until the update is done. We generally don’t take external clients into account.

The most common situation I’ve seen, then there has always been other technical issues that warranted a multiple release cycle. Although sounds like a neat feature but I have a hard time imagining a real world use case.

No, database connections internally cache information from previous queries. Reusing that cache is what I meant. BTW, that may also be a reason to drop the connection after a period, because it could lead to excessive memory use.

Okay, this makes sense. Connections cannot be long lived and need to be refreshed.

So we now have a SOTA pool not only on the BEAM but across all languages (AFAIK), some of the things I listed didn’t make it, mostly becuase they ended up being completely unnecessary when you lean into the BEAM.

import Ecto.Query
defmodule SQL.Repo do
  use Ecto.Repo, otp_app: :sql, adapter: Ecto.Adapters.Postgres
  use SQL, adapter: SQL.Adapters.Postgres
  import Ecto.Query
  def sql(type \\ :transaction)
  def sql(:statement) do
    Enum.to_list(~SQL"SELECT 1")
  end
  def sql(:empty) do
    SQL.transaction do
      :ok
    end
  end
  def sql(:transaction) do
    SQL.transaction do
      Enum.to_list(~SQL"SELECT 1")
    end
  end
  def sql(:savepoint) do
    SQL.transaction do
      SQL.transaction do
        Enum.to_list(~SQL"SELECT 1")
      end
    end
  end
  def sql(:cursor) do
    SQL.transaction do
      Stream.run(~SQL"SELECT g, repeat(md5(g::text), 4) FROM generate_series(1, 5000000) AS g")
    end
  end

  def ecto(type \\ :transaction)
  def ecto(:statement) do
    SQL.Repo.all(select(from("users"), [1]))
  end
  def ecto(:empty) do
    SQL.Repo.transaction(fn ->
      :ok
    end)
  end
  def ecto(:transaction) do
    SQL.Repo.transaction(fn ->
      SQL.Repo.all(select(from("users"), [1]))
    end)
  end
  def ecto(:savepoint) do
    SQL.Repo.transaction(fn ->
      SQL.Repo.transaction(fn ->
        SQL.Repo.all(select(from("users"), [1]))
      end)
    end)
  end
  def ecto(:cursor) do
    SQL.Repo.transaction(fn ->
      from(row in fragment("SELECT g, repeat(md5(g::text), 4) FROM generate_series(1, ?) AS g", 5000000), select: {fragment("?::int", row.g), fragment("?::text", row.repeat)})
      |> SQL.Repo.stream()
      |> Stream.run()
    end)
  end
end
Application.put_env(:sql, :ecto_repos, [SQL.Repo])
Application.put_env(:sql, SQL.Repo, log: false, username: "postgres", password: "postgres", hostname: "localhost", database: "sql_test#{System.get_env("MIX_TEST_PARTITION")}", pool_size: :erlang.system_info(:schedulers_online), ssl: false)
SQL.Repo.__adapter__().storage_up(SQL.Repo.config())
SQL.Repo.start_link()

Benchee.run(
  %{
  "sql" => fn -> SQL.Repo.sql(:transaction) end,
  "ecto" => fn -> SQL.Repo.ecto(:transaction) end,
  },
  parallel: 500,
  warmup: 10,
  memory_time: 2,
  reduction_time: 2,
  unit_scaling: :smallest,
  measure_function_call_overhead: true
)

➜ sql git:(main) ✗ mix sql.bench
Operating System: macOS
CPU Information: Apple M1 Max
Number of Available Cores: 10
Available memory: 64 GB
Elixir 1.20.0-dev
Erlang 28.1
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 10 s
time: 5 s
memory time: 2 s
reduction time: 2 s
parallel: 500
inputs: none specified
Estimated total run time: 38 s

Measured function call overhead as: 0 ns
Benchmarking ecto …
Benchmarking sql …
Calculating statistics…
Formatting results…

Name ips average deviation median 99th %
sql 5507.15 181.58 μs ±1576.97% 177.13 μs 324.92 μs
ecto 56.22 17788.30 μs ±8.30% 17331.33 μs 22480.54 μs

Comparison:
sql 5507.15
ecto 56.22 - 97.96x slower +17606.72 μs

Memory usage statistics:

Name average deviation median 99th %
sql 984.00 B ±0.02% 984 B 984 B
ecto 17952.31 B ±0.03% 17952 B 17952 B

Comparison:
sql 984 B
ecto 17952.31 B - 18.24x memory usage +16968.31 B

Reduction count statistics:

Name average deviation median 99th %
sql 0.0920 K ±0.03% 0.0920 K 0.0920 K
ecto 1.56 K ±0.30% 1.56 K 1.56 K

Comparison:
sql 0.0920 K
ecto 1.56 K - 16.91x reduction count +1.46 K