This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 7d238ec5 [elixir] feat: Add first batch of admin calls (#583)
7d238ec5 is described below
commit 7d238ec57ee99bcaf6545736679828608200f479
Author: Nicoleta Lazar <[email protected]>
AuthorDate: Mon Jun 1 02:30:29 2026 +0100
[elixir] feat: Add first batch of admin calls (#583)
This commit adds three admin methods on `Fluss.Admin` for:
- checking whether a database exists
- checking whether a table exists
- retrieving the server nodes
---
bindings/elixir/lib/fluss/admin.ex | 45 +++++++
bindings/elixir/lib/fluss/native.ex | 7 ++
bindings/elixir/lib/fluss/server_node.ex | 37 ++++++
bindings/elixir/native/fluss_nif/src/admin.rs | 67 ++++++++++-
bindings/elixir/test/integration/admin_test.exs | 133 +++++++++++++++++++++
.../elixir/test/integration/log_table_test.exs | 43 +------
bindings/elixir/test/support/cluster.ex | 22 ++++
bindings/elixir/test/support/integration_case.ex | 43 +++++++
8 files changed, 354 insertions(+), 43 deletions(-)
diff --git a/bindings/elixir/lib/fluss/admin.ex
b/bindings/elixir/lib/fluss/admin.ex
index 6dbdb3a9..3be8e96f 100644
--- a/bindings/elixir/lib/fluss/admin.ex
+++ b/bindings/elixir/lib/fluss/admin.ex
@@ -50,6 +50,21 @@ defmodule Fluss.Admin do
end
end
+ @spec get_server_nodes(t()) :: {:ok, [Fluss.ServerNode.t()]} | {:error,
Fluss.Error.t()}
+ def get_server_nodes(admin) do
+ admin
+ |> Native.admin_get_server_nodes()
+ |> Native.await_nif()
+ end
+
+ @spec get_server_nodes!(t()) :: [Fluss.ServerNode.t()]
+ def get_server_nodes!(admin) do
+ case get_server_nodes(admin) do
+ {:ok, nodes} -> nodes
+ {:error, %Fluss.Error{} = err} -> raise err
+ end
+ end
+
@spec create_database(t(), String.t(), boolean()) :: :ok | {:error,
Fluss.Error.t()}
def create_database(admin, name, ignore_if_exists \\ true) do
admin
@@ -79,6 +94,21 @@ defmodule Fluss.Admin do
end
end
+ @spec database_exists(t(), String.t()) :: {:ok, boolean()} | {:error,
Fluss.Error.t()}
+ def database_exists(admin, database_name) do
+ admin
+ |> Native.admin_database_exists(database_name)
+ |> Native.await_nif()
+ end
+
+ @spec database_exists!(t(), String.t()) :: boolean()
+ def database_exists!(admin, database_name) do
+ case database_exists(admin, database_name) do
+ {:ok, exists} -> exists
+ {:error, %Fluss.Error{} = err} -> raise err
+ end
+ end
+
@spec create_table(t(), String.t(), String.t(), Fluss.TableDescriptor.t(),
boolean()) ::
:ok | {:error, Fluss.Error.t()}
def create_table(admin, database, table, descriptor, ignore_if_exists \\
true) do
@@ -108,4 +138,19 @@ defmodule Fluss.Admin do
{:error, %Fluss.Error{} = err} -> raise err
end
end
+
+ @spec table_exists(t(), String.t(), String.t()) :: {:ok, boolean()} |
{:error, Fluss.Error.t()}
+ def table_exists(admin, database_name, table_name) do
+ admin
+ |> Native.admin_table_exists(database_name, table_name)
+ |> Native.await_nif()
+ end
+
+ @spec table_exists!(t(), String.t(), String.t()) :: boolean()
+ def table_exists!(admin, database_name, table_name) do
+ case table_exists(admin, database_name, table_name) do
+ {:ok, exists} -> exists
+ {:error, %Fluss.Error{} = err} -> raise err
+ end
+ end
end
diff --git a/bindings/elixir/lib/fluss/native.ex
b/bindings/elixir/lib/fluss/native.ex
index 865dda14..b0e41f04 100644
--- a/bindings/elixir/lib/fluss/native.ex
+++ b/bindings/elixir/lib/fluss/native.ex
@@ -25,6 +25,8 @@ defmodule Fluss.Native do
# Admin
def admin_new(_conn), do: :erlang.nif_error(:nif_not_loaded)
+ def admin_get_server_nodes(_admin), do: :erlang.nif_error(:nif_not_loaded)
+
def admin_create_database(_admin, _name, _ignore_if_exists),
do: :erlang.nif_error(:nif_not_loaded)
@@ -33,6 +35,8 @@ defmodule Fluss.Native do
def admin_list_databases(_admin), do: :erlang.nif_error(:nif_not_loaded)
+ def admin_database_exists(_admin, _database_name), do:
:erlang.nif_error(:nif_not_loaded)
+
def admin_create_table(_admin, _db, _table, _descriptor, _ignore_if_exists),
do: :erlang.nif_error(:nif_not_loaded)
@@ -41,6 +45,9 @@ defmodule Fluss.Native do
def admin_list_tables(_admin, _database), do:
:erlang.nif_error(:nif_not_loaded)
+ def admin_table_exists(_admin, _database_name, _table_name),
+ do: :erlang.nif_error(:nif_not_loaded)
+
# Schema / TableDescriptor
def table_descriptor_new(_schema, _bucket_count, _properties),
do: :erlang.nif_error(:nif_not_loaded)
diff --git a/bindings/elixir/lib/fluss/server_node.ex
b/bindings/elixir/lib/fluss/server_node.ex
new file mode 100644
index 00000000..5441aa23
--- /dev/null
+++ b/bindings/elixir/lib/fluss/server_node.ex
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+defmodule Fluss.ServerNode do
+ @moduledoc """
+ A node in the Fluss cluster — either a coordinator or a tablet server.
+ Returned by `Fluss.Admin.get_server_nodes/1`.
+ """
+
+ @enforce_keys [:id, :uid, :host, :port, :server_type]
+ defstruct [:id, :uid, :host, :port, :server_type]
+
+ @type t :: %__MODULE__{
+ id: integer(),
+ uid: String.t(),
+ host: String.t(),
+ port: non_neg_integer(),
+ server_type: :tablet_server | :coordinator_server
+ }
+
+ @spec url(t()) :: String.t()
+ def url(%__MODULE__{host: host, port: port}), do: "#{host}:#{port}"
+end
diff --git a/bindings/elixir/native/fluss_nif/src/admin.rs
b/bindings/elixir/native/fluss_nif/src/admin.rs
index e3f29aeb..ddb83e68 100644
--- a/bindings/elixir/native/fluss_nif/src/admin.rs
+++ b/bindings/elixir/native/fluss_nif/src/admin.rs
@@ -21,9 +21,41 @@ use crate::connection::ConnectionResource;
use crate::schema::TableDescriptorResource;
use fluss::client::FlussAdmin;
use fluss::metadata::TablePath;
-use rustler::{Env, ResourceArc, Term};
+use fluss::{ServerNode, ServerType};
+use rustler::{Env, NifStruct, NifUnitEnum, ResourceArc, Term};
use std::sync::Arc;
+#[derive(NifUnitEnum)]
+pub enum NifServerType {
+ TabletServer,
+ CoordinatorServer,
+}
+
+#[derive(NifStruct)]
+#[module = "Fluss.ServerNode"]
+pub struct NifServerNode {
+ pub id: i32,
+ pub uid: String,
+ pub host: String,
+ pub port: u32,
+ pub server_type: NifServerType,
+}
+
+impl NifServerNode {
+ pub fn from_core(node: &ServerNode) -> Self {
+ Self {
+ id: node.id(),
+ uid: node.uid().to_string(),
+ host: node.host().to_string(),
+ port: node.port(),
+ server_type: match node.server_type() {
+ ServerType::TabletServer => NifServerType::TabletServer,
+ ServerType::CoordinatorServer =>
NifServerType::CoordinatorServer,
+ },
+ }
+ }
+}
+
pub struct AdminResource {
pub inner: Arc<FlussAdmin>,
}
@@ -41,6 +73,15 @@ fn admin_new(
Ok(ResourceArc::new(AdminResource { inner }))
}
+#[rustler::nif]
+fn admin_get_server_nodes<'a>(env: Env<'a>, admin: ResourceArc<AdminResource>)
-> Term<'a> {
+ async_nif::spawn_task_with_result(env, async move {
+ let nodes: Vec<ServerNode> = admin.inner.get_server_nodes().await?;
+ let wrapped: Vec<NifServerNode> =
nodes.iter().map(NifServerNode::from_core).collect();
+ Ok(wrapped)
+ })
+}
+
#[rustler::nif]
fn admin_create_database<'a>(
env: Env<'a>,
@@ -76,6 +117,17 @@ fn admin_list_databases<'a>(env: Env<'a>, admin:
ResourceArc<AdminResource>) ->
async_nif::spawn_task_with_result(env, async move {
admin.inner.list_databases().await })
}
+#[rustler::nif]
+fn admin_database_exists<'a>(
+ env: Env<'a>,
+ admin: ResourceArc<AdminResource>,
+ database_name: String,
+) -> Term<'a> {
+ async_nif::spawn_task_with_result(env, async move {
+ admin.inner.database_exists(&database_name).await
+ })
+}
+
#[rustler::nif]
fn admin_create_table<'a>(
env: Env<'a>,
@@ -119,3 +171,16 @@ fn admin_list_tables<'a>(
async move { admin.inner.list_tables(&database_name).await },
)
}
+
+#[rustler::nif]
+fn admin_table_exists<'a>(
+ env: Env<'a>,
+ admin: ResourceArc<AdminResource>,
+ database_name: String,
+ table_name: String,
+) -> Term<'a> {
+ async_nif::spawn_task_with_result(env, async move {
+ let table_path = TablePath::new(database_name, table_name);
+ admin.inner.table_exists(&table_path).await
+ })
+}
diff --git a/bindings/elixir/test/integration/admin_test.exs
b/bindings/elixir/test/integration/admin_test.exs
new file mode 100644
index 00000000..1f670350
--- /dev/null
+++ b/bindings/elixir/test/integration/admin_test.exs
@@ -0,0 +1,133 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+defmodule Fluss.Integration.AdminTest do
+ use Fluss.Test.IntegrationCase, async: false
+
+ @database "fluss"
+
+ describe "get_server_nodes/1" do
+ test "returns a non-empty list of %Fluss.ServerNode{} structs", %{admin:
admin} do
+ assert {:ok, nodes} = Fluss.Admin.get_server_nodes(admin)
+ assert is_list(nodes)
+ assert nodes != []
+
+ for node <- nodes do
+ assert %Fluss.ServerNode{} = node
+ assert is_integer(node.id)
+ assert is_binary(node.uid) and node.uid != ""
+ assert is_binary(node.host) and node.host != ""
+ assert is_integer(node.port) and node.port > 0
+ assert node.server_type in [:coordinator_server, :tablet_server]
+ end
+ end
+
+ test "cluster has exactly one coordinator", %{admin: admin} do
+ {:ok, nodes} = Fluss.Admin.get_server_nodes(admin)
+ coordinators = Enum.filter(nodes, &(&1.server_type ==
:coordinator_server))
+
+ assert [_coordinator] = coordinators
+ end
+ end
+
+ describe "get_server_nodes!/1" do
+ test "returns the list directly without an :ok tuple", %{admin: admin} do
+ nodes = Fluss.Admin.get_server_nodes!(admin)
+ assert is_list(nodes)
+ assert nodes != []
+ assert %Fluss.ServerNode{} = hd(nodes)
+ end
+ end
+
+ describe "database_exists/2" do
+ test "returns {:ok, true} for an existing database", %{admin: admin} do
+ db = "fluss_data_sources_#{:rand.uniform(100_000)}"
+
+ :ok = Fluss.Admin.create_database(admin, db, true)
+ on_exit(fn -> Fluss.Admin.drop_database(admin, db, true) end)
+
+ assert {:ok, true} = Fluss.Admin.database_exists(admin, db)
+ end
+
+ test "returns {:ok, false} for a non-existent database", %{admin: admin} do
+ db = "fluss_data_sources_#{:rand.uniform(100_000)}"
+
+ assert Fluss.Admin.database_exists(admin, db) == {:ok, false}
+ end
+ end
+
+ describe "database_exists!/2" do
+ test "returns true for an existing database", %{admin: admin} do
+ db = "fluss_data_sources_#{:rand.uniform(100_000)}"
+
+ :ok = Fluss.Admin.create_database(admin, db)
+ on_exit(fn -> Fluss.Admin.drop_database(admin, db, true) end)
+
+ assert Fluss.Admin.database_exists!(admin, db)
+ end
+
+ test "returns false for a non-existent database", %{admin: admin} do
+ db = "fluss_data_sources_#{:rand.uniform(100_000)}"
+
+ refute Fluss.Admin.database_exists!(admin, db)
+ end
+ end
+
+ describe "table_exists/3" do
+ test "returns {:ok, true} for an existing table", %{admin: admin} do
+ table = "fluss_table_#{:rand.uniform(100_000)}"
+
+ schema =
+ Fluss.Schema.new()
+ |> Fluss.Schema.column("c1", :int)
+ |> Fluss.Schema.column("c2", :string)
+
+ descriptor = Fluss.TableDescriptor.new!(schema)
+ :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+ on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+ assert {:ok, true} = Fluss.Admin.table_exists(admin, @database, table)
+ end
+
+ test "returns {:ok, false} for a non-existent table", %{admin: admin} do
+ table = "fluss_table_#{:rand.uniform(100_000)}"
+ assert {:ok, false} = Fluss.Admin.table_exists(admin, @database, table)
+ end
+ end
+
+ describe "table_exists!/3" do
+ test "returns true for an existing table", %{admin: admin} do
+ table = "fluss_table_#{:rand.uniform(100_000)}"
+
+ schema =
+ Fluss.Schema.new()
+ |> Fluss.Schema.column("c1", :int)
+ |> Fluss.Schema.column("c2", :string)
+
+ descriptor = Fluss.TableDescriptor.new!(schema)
+ :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+ on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+ assert Fluss.Admin.table_exists!(admin, @database, table)
+ end
+
+ test "returns false for a non-existent table", %{admin: admin} do
+ table = "fluss_table_#{:rand.uniform(100_000)}"
+ refute Fluss.Admin.table_exists!(admin, @database, table)
+ end
+ end
+end
diff --git a/bindings/elixir/test/integration/log_table_test.exs
b/bindings/elixir/test/integration/log_table_test.exs
index b3041b95..16a670fe 100644
--- a/bindings/elixir/test/integration/log_table_test.exs
+++ b/bindings/elixir/test/integration/log_table_test.exs
@@ -16,29 +16,10 @@
# under the License.
defmodule Fluss.Integration.LogTableTest do
- use ExUnit.Case, async: false
-
- alias Fluss.Test.Cluster
-
- @moduletag :integration
+ use Fluss.Test.IntegrationCase, async: false
@database "fluss"
- setup_all do
- case Cluster.ensure_started() do
- {:ok, servers} ->
- config = Fluss.Config.new(servers)
-
- # Wait for cluster to be fully ready (connection + admin working)
- {conn, admin} = connect_with_retry(config, 90)
-
- %{conn: conn, admin: admin, config: config}
-
- {:error, reason} ->
- raise "Failed to start Fluss cluster: #{reason}"
- end
- end
-
describe "append and scan" do
test "append rows and scan with log scanner", %{conn: conn, admin: admin}
do
table_name = "ex_test_append_and_scan_#{:rand.uniform(100_000)}"
@@ -388,26 +369,4 @@ defmodule Fluss.Integration.LogTableTest do
defp cleanup_table(admin, table_name) do
Fluss.Admin.drop_table(admin, @database, table_name, true)
end
-
- defp connect_with_retry(config, timeout_s) do
- deadline = System.monotonic_time(:second) + timeout_s
- do_connect_retry(config, deadline, nil)
- end
-
- defp do_connect_retry(config, deadline, last_error) do
- if System.monotonic_time(:second) >= deadline do
- raise "Could not connect to Fluss cluster: #{inspect(last_error)}"
- end
-
- try do
- conn = Fluss.Connection.new!(config)
- admin = Fluss.Admin.new!(conn)
- {:ok, _databases} = Fluss.Admin.list_databases(admin)
- {conn, admin}
- rescue
- e ->
- Process.sleep(2_000)
- do_connect_retry(config, deadline, e)
- end
- end
end
diff --git a/bindings/elixir/test/support/cluster.ex
b/bindings/elixir/test/support/cluster.ex
index 40f0f68d..e6ff691d 100644
--- a/bindings/elixir/test/support/cluster.ex
+++ b/bindings/elixir/test/support/cluster.ex
@@ -46,6 +46,28 @@ defmodule Fluss.Test.Cluster do
end
end
+ def connect_with_retry(config, timeout_s) do
+ deadline = System.monotonic_time(:second) + timeout_s
+ do_connect_retry(config, deadline, nil)
+ end
+
+ defp do_connect_retry(config, deadline, last_error) do
+ if System.monotonic_time(:second) >= deadline do
+ raise "Could not connect to Fluss cluster: #{inspect(last_error)}"
+ end
+
+ try do
+ conn = Fluss.Connection.new!(config)
+ admin = Fluss.Admin.new!(conn)
+ {:ok, _databases} = Fluss.Admin.list_databases(admin)
+ {conn, admin}
+ rescue
+ e ->
+ Process.sleep(2_000)
+ do_connect_retry(config, deadline, e)
+ end
+ end
+
defp start_cluster do
with {:ok, cli} <- find_cli_binary(),
{output, 0} <-
diff --git a/bindings/elixir/test/support/integration_case.ex
b/bindings/elixir/test/support/integration_case.ex
new file mode 100644
index 00000000..b5aa91a6
--- /dev/null
+++ b/bindings/elixir/test/support/integration_case.ex
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+defmodule Fluss.Test.IntegrationCase do
+ @moduledoc """
+ Testing Template for Fluss integration tests
+ """
+ use ExUnit.CaseTemplate
+ alias Fluss.Test.Cluster
+
+ using do
+ quote do
+ @moduletag :integration
+ alias Fluss.Test.Cluster
+ end
+ end
+
+ setup_all do
+ case Cluster.ensure_started() do
+ {:ok, servers} ->
+ config = Fluss.Config.new(servers)
+ {conn, admin} = Cluster.connect_with_retry(config, 90)
+ %{conn: conn, admin: admin, config: config}
+
+ {:error, reason} ->
+ raise "Failed to start Fluss cluster: #{reason}"
+ end
+ end
+end