mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
feat: use SQL for remote function lifecycle
Remote function registration, exact-version lookup, listing, and drop now execute SQL through the Flight client. Registration waits for the durable image build and catalog publication before yielding its typed `FunctionVersion`. SQL also covers source-inclusive description and pinned metadata updates. Registration and drop handles use `job_type="query"`; the single `job.id` is the SQL query UUID and can be inspected with `describe_query()` on the submitting connection. Waiting drains the SQL result once and caches completion for repeated waits. The drop boolean reports catalog removal; physical cleanup continues separately. Query handles have no registry history and cannot be reopened with `open_job()`.
This commit is contained in:
@@ -41,6 +41,7 @@ def connect_enterprise_quickstart_config():
|
||||
api_key=os.environ["LANCEDB_API_KEY"],
|
||||
region=os.environ.get("LANCEDB_REGION", "us-east-1"),
|
||||
host_override=os.environ["LANCEDB_HOST_OVERRIDE"], # https://your-endpoint
|
||||
sql_host_override=os.environ["LANCEDB_SQL_HOST_OVERRIDE"],
|
||||
)
|
||||
# --8<-- [end:connect_enterprise_quickstart]
|
||||
return db
|
||||
@@ -60,6 +61,9 @@ def test_connect_enterprise_quickstart(monkeypatch):
|
||||
monkeypatch.setenv("LANCEDB_API_KEY", "your-api-key")
|
||||
monkeypatch.delenv("LANCEDB_REGION", raising=False)
|
||||
monkeypatch.setenv("LANCEDB_HOST_OVERRIDE", "https://your-endpoint")
|
||||
monkeypatch.setenv(
|
||||
"LANCEDB_SQL_HOST_OVERRIDE", "grpc+tls://your-sql-endpoint:10026"
|
||||
)
|
||||
|
||||
assert connect_enterprise_quickstart_config() is not None
|
||||
assert captured == {
|
||||
@@ -67,6 +71,7 @@ def test_connect_enterprise_quickstart(monkeypatch):
|
||||
"api_key": "your-api-key",
|
||||
"region": "us-east-1",
|
||||
"host_override": "https://your-endpoint",
|
||||
"sql_host_override": "grpc+tls://your-sql-endpoint:10026",
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +84,16 @@ def test_connect_enterprise_quickstart_requires_an_endpoint(monkeypatch):
|
||||
connect_enterprise_quickstart_config()
|
||||
|
||||
|
||||
def test_connect_enterprise_quickstart_requires_a_sql_endpoint(monkeypatch):
|
||||
monkeypatch.setenv("LANCEDB_URI", "db://your-database")
|
||||
monkeypatch.setenv("LANCEDB_API_KEY", "your-api-key")
|
||||
monkeypatch.setenv("LANCEDB_HOST_OVERRIDE", "https://your-endpoint")
|
||||
monkeypatch.delenv("LANCEDB_SQL_HOST_OVERRIDE", raising=False)
|
||||
|
||||
with pytest.raises(KeyError, match="LANCEDB_SQL_HOST_OVERRIDE"):
|
||||
connect_enterprise_quickstart_config()
|
||||
|
||||
|
||||
def connect_object_storage_config():
|
||||
# --8<-- [start:connect_object_storage]
|
||||
import lancedb
|
||||
@@ -169,6 +184,7 @@ def test_namespace_admin_ops(tmp_path, monkeypatch, capsys):
|
||||
)
|
||||
assert db.list_namespaces().namespaces == []
|
||||
|
||||
|
||||
async def connect_object_storage_config_async():
|
||||
# --8<-- [start:connect_object_storage_async]
|
||||
import lancedb
|
||||
@@ -199,6 +215,7 @@ def test_connect_enterprise_placeholders(monkeypatch):
|
||||
api_key="your-api-key",
|
||||
region="us-east-1",
|
||||
host_override="https://your-endpoint",
|
||||
sql_host_override="grpc+tls://your-sql-endpoint:10026",
|
||||
)
|
||||
# --8<-- [end:connect_enterprise_placeholders]
|
||||
assert db is not None
|
||||
@@ -207,4 +224,5 @@ def test_connect_enterprise_placeholders(monkeypatch):
|
||||
"api_key": "your-api-key",
|
||||
"region": "us-east-1",
|
||||
"host_override": "https://your-endpoint",
|
||||
"sql_host_override": "grpc+tls://your-sql-endpoint:10026",
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
Registering a Function and filling its column need a LanceDB Enterprise
|
||||
deployment, so `test_function_enterprise` runs only when
|
||||
LANCEDB_DOCS_TEST_DEPLOYMENT=1 asks for one, with the connection variables the
|
||||
Enterprise examples already use: LANCEDB_URI, LANCEDB_API_KEY and
|
||||
LANCEDB_HOST_OVERRIDE, plus LANCEDB_REGION if the deployment needs one. The
|
||||
Enterprise examples already use: LANCEDB_URI, LANCEDB_API_KEY,
|
||||
LANCEDB_HOST_OVERRIDE and LANCEDB_SQL_HOST_OVERRIDE, plus LANCEDB_REGION if the
|
||||
deployment needs one. The
|
||||
walkthrough calls its table `function_demo` and its Function `double`; the test
|
||||
creates both under names unique to its run, never replaces an existing object,
|
||||
and drops only what it created.
|
||||
@@ -91,7 +92,12 @@ def registered_version(definition) -> FunctionVersion:
|
||||
return FunctionVersion.from_json(json.dumps(reply))
|
||||
|
||||
|
||||
DEPLOYMENT = ("LANCEDB_URI", "LANCEDB_HOST_OVERRIDE", "LANCEDB_API_KEY")
|
||||
DEPLOYMENT = (
|
||||
"LANCEDB_URI",
|
||||
"LANCEDB_HOST_OVERRIDE",
|
||||
"LANCEDB_SQL_HOST_OVERRIDE",
|
||||
"LANCEDB_API_KEY",
|
||||
)
|
||||
# The connection settings alone never send this test to a deployment.
|
||||
OPT_IN = "LANCEDB_DOCS_TEST_DEPLOYMENT"
|
||||
|
||||
@@ -181,7 +187,10 @@ def test_function_version_binds_a_column(tmp_path):
|
||||
# The binding is accepted by add_columns, which a local table refuses:
|
||||
# a Function column needs LanceDB Cloud or Enterprise.
|
||||
table = lancedb.connect(tmp_path).create_table("t", [{"value": 1.0}])
|
||||
with pytest.raises(NotImplementedError, match="Cloud and Enterprise"):
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match="Function columns are supported only in LanceDB Distributed",
|
||||
):
|
||||
table.add_columns({"doubled": application})
|
||||
|
||||
|
||||
@@ -223,6 +232,7 @@ def test_function_enterprise(monkeypatch):
|
||||
api_key=os.environ["LANCEDB_API_KEY"],
|
||||
region=os.environ.get("LANCEDB_REGION", "us-east-1"),
|
||||
host_override=os.environ["LANCEDB_HOST_OVERRIDE"],
|
||||
sql_host_override=os.environ["LANCEDB_SQL_HOST_OVERRIDE"],
|
||||
)
|
||||
# --8<-- [end:function_connect]
|
||||
|
||||
@@ -298,6 +308,7 @@ class ReachedConnection(Exception):
|
||||
SETTINGS = {
|
||||
"LANCEDB_URI": "db://docs-test",
|
||||
"LANCEDB_HOST_OVERRIDE": "https://deployment.invalid",
|
||||
"LANCEDB_SQL_HOST_OVERRIDE": "grpc+tls://sql.deployment.invalid:10026",
|
||||
"LANCEDB_API_KEY": "docs-test-key",
|
||||
}
|
||||
|
||||
@@ -375,5 +386,6 @@ def test_function_enterprise_connects_when_asked(monkeypatch, connect_attempts,
|
||||
"api_key": "docs-test-key",
|
||||
"region": region or "us-east-1",
|
||||
"host_override": "https://deployment.invalid",
|
||||
"sql_host_override": "grpc+tls://sql.deployment.invalid:10026",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -93,6 +93,12 @@ async function connectObjectStorageExample() {
|
||||
|
||||
async function namespaceTableOpsExample() {
|
||||
// --8<-- [start:namespace_table_ops]
|
||||
async function collectNames(items: AsyncIterable<string>): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
for await (const name of items) names.push(name);
|
||||
return names;
|
||||
}
|
||||
|
||||
const db = await lancedb.connectNamespace("dir", { root: "./local_lancedb" });
|
||||
|
||||
// Create namespace tree: prod/search and prod/recommendations
|
||||
@@ -124,6 +130,12 @@ async function namespaceTableOpsExample() {
|
||||
|
||||
async function namespaceAdminOpsExample() {
|
||||
// --8<-- [start:namespace_admin_ops]
|
||||
async function collectNames(items: AsyncIterable<string>): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
for await (const name of items) names.push(name);
|
||||
return names;
|
||||
}
|
||||
|
||||
const db = await lancedb.connectNamespace("dir", { root: "./local_lancedb" });
|
||||
const namespace = ["prod", "search"];
|
||||
|
||||
@@ -131,7 +143,10 @@ async function namespaceAdminOpsExample() {
|
||||
await db.createNamespace(["prod", "search"]);
|
||||
|
||||
const childNamespaces = await collectNames(db.listNamespaces(["prod"]));
|
||||
console.log(`Child namespaces under ${JSON.stringify(namespace)}:`, childNamespaces);
|
||||
console.log(
|
||||
`Child namespaces under ${JSON.stringify(namespace)}:`,
|
||||
childNamespaces,
|
||||
);
|
||||
// Child namespaces under ["prod","search"]: [ 'search' ]
|
||||
|
||||
const metadata = await db.describeNamespace(["prod", "search"]);
|
||||
|
||||
@@ -119,6 +119,12 @@ and `cancel()`. Synchronous creation, refresh, and drop wait on the same SQL
|
||||
execution before returning. A completed DROP means the catalog binding has been
|
||||
removed; physical cleanup continues in the background.
|
||||
|
||||
Remote function registration also returns a SQL query job. The SQL operation
|
||||
submits the durable image build and waits for catalog publication before returning
|
||||
the immutable FunctionVersion. `drop_function_async()` returns the catalog-removal
|
||||
boolean together with a query job; waiting on that handle confirms removal while
|
||||
physical cleanup continues separately.
|
||||
|
||||
The existing `job.job_type` identifies query-backed handles as `"query"` from
|
||||
submission, and `job.id` contains the connection-scoped SQL query UUID string.
|
||||
Use `describe_query(job.id)` on the submitting connection to inspect the query.
|
||||
|
||||
@@ -91,7 +91,9 @@ deployment's support before using another computed column as an input.
|
||||
|
||||
## Register it {#register}
|
||||
|
||||
Registration returns a job; the immutable version exists once the job succeeds.
|
||||
Registration returns a query-backed job; the immutable version exists once the
|
||||
SQL command finishes and the job succeeds. Its `job_type` is `query`, and its
|
||||
single `id` is the SQL query UUID. Repeated waits reuse the completed result.
|
||||
|
||||
<CodeBlock filename="Python" language="Python" icon="python">
|
||||
{FunctionRegister}
|
||||
@@ -101,8 +103,9 @@ Registration returns a job; the immutable version exists once the job succeeds.
|
||||
with `db.get_function(name, version=...)`.
|
||||
|
||||
<Note>
|
||||
**Enterprise job type:** `create_function`. Registration returns a job because
|
||||
the function is baked into an image before the version becomes usable.
|
||||
The SQL command submits an internal `create_function` image-build job and
|
||||
waits for publication. The SDK returns the SQL query handle. Query handles
|
||||
have no registry history and cannot be reopened with `open_job()`.
|
||||
</Note>
|
||||
|
||||
## Try it in SQL {#try-sql}
|
||||
@@ -565,7 +568,9 @@ itself is: as source, snapshotted at registration.
|
||||
it is bound to; `version` must be a version that exists. It returns `False` if
|
||||
the name was already gone. Deleting the object's content can finish after the
|
||||
call returns, as a [background job](/maintenance/background-jobs).
|
||||
`drop_function_async` returns that job with the result, so you can wait for it.
|
||||
`drop_function_async` returns `(dropped, job)`, where `job` is a query-backed
|
||||
handle. Waiting confirms catalog
|
||||
removal; physical cleanup can continue afterward.
|
||||
|
||||
A deployment refuses the drop unless it also allows changes to a function's
|
||||
lifecycle, which is a separate setting, off by default even where Functions are
|
||||
|
||||
@@ -4,9 +4,9 @@ export const PyConnect = "import lancedb\n\nuri = \"ex_lancedb\"\ndb = lancedb.c
|
||||
|
||||
export const PyConnectAsync = "import lancedb\n\nuri = \"ex_lancedb\"\nasync_db = await lancedb.connect_async(uri)\n";
|
||||
|
||||
export const PyConnectEnterprisePlaceholders = "import lancedb\n\ndb = lancedb.connect(\n uri=\"db://your-database\",\n api_key=\"your-api-key\",\n region=\"us-east-1\",\n host_override=\"https://your-endpoint\",\n)\n";
|
||||
export const PyConnectEnterprisePlaceholders = "import lancedb\n\ndb = lancedb.connect(\n uri=\"db://your-database\",\n api_key=\"your-api-key\",\n region=\"us-east-1\",\n host_override=\"https://your-endpoint\",\n sql_host_override=\"grpc+tls://your-sql-endpoint:10026\",\n)\n";
|
||||
|
||||
export const PyConnectEnterpriseQuickstart = "import os\n\nimport lancedb\n\ndb = lancedb.connect(\n uri=os.environ[\"LANCEDB_URI\"], # db://your-database\n api_key=os.environ[\"LANCEDB_API_KEY\"],\n region=os.environ.get(\"LANCEDB_REGION\", \"us-east-1\"),\n host_override=os.environ[\"LANCEDB_HOST_OVERRIDE\"], # https://your-endpoint\n)\n";
|
||||
export const PyConnectEnterpriseQuickstart = "import os\n\nimport lancedb\n\ndb = lancedb.connect(\n uri=os.environ[\"LANCEDB_URI\"], # db://your-database\n api_key=os.environ[\"LANCEDB_API_KEY\"],\n region=os.environ.get(\"LANCEDB_REGION\", \"us-east-1\"),\n host_override=os.environ[\"LANCEDB_HOST_OVERRIDE\"], # https://your-endpoint\n sql_host_override=os.environ[\"LANCEDB_SQL_HOST_OVERRIDE\"],\n)\n";
|
||||
|
||||
export const PyConnectObjectStorage = "import lancedb\n\nuri = \"s3://your-bucket/path\"\n# You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\ndb = lancedb.connect(uri)\n";
|
||||
|
||||
@@ -24,9 +24,9 @@ export const TsConnectEnterpriseQuickstart = "const setting = (name: string) =>
|
||||
|
||||
export const TsConnectObjectStorage = "const uri = \"s3://your-bucket/path\";\n// You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\nconst db = await lancedb.connect(uri);\n";
|
||||
|
||||
export const TsNamespaceAdminOps = "const db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\nconst namespace = [\"prod\", \"search\"];\n\nawait db.createNamespace([\"prod\"]);\nawait db.createNamespace([\"prod\", \"search\"]);\n\nconst childNamespaces = (await db.listNamespaces([\"prod\"])).namespaces;\nconsole.log(`Child namespaces under ${JSON.stringify(namespace)}:`, childNamespaces);\n// Child namespaces under [\"prod\",\"search\"]: [ 'search' ]\n\nconst metadata = await db.describeNamespace([\"prod\", \"search\"]);\nconsole.log(`Metadata for namespace ${JSON.stringify(namespace)}:`, metadata);\n\nawait db.dropNamespace([\"prod\", \"search\"], { mode: \"skip\" });\nawait db.dropNamespace([\"prod\"], { mode: \"skip\" });\n";
|
||||
export const TsNamespaceAdminOps = "async function collectNames(items: AsyncIterable<string>): Promise<string[]> {\n const names: string[] = [];\n for await (const name of items) names.push(name);\n return names;\n}\n\nconst db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\nconst namespace = [\"prod\", \"search\"];\n\nawait db.createNamespace([\"prod\"]);\nawait db.createNamespace([\"prod\", \"search\"]);\n\nconst childNamespaces = await collectNames(db.listNamespaces([\"prod\"]));\nconsole.log(\n `Child namespaces under ${JSON.stringify(namespace)}:`,\n childNamespaces,\n);\n// Child namespaces under [\"prod\",\"search\"]: [ 'search' ]\n\nconst metadata = await db.describeNamespace([\"prod\", \"search\"]);\nconsole.log(`Metadata for namespace ${JSON.stringify(namespace)}:`, metadata);\n\nawait db.dropNamespace([\"prod\", \"search\"], { mode: \"skip\" });\nawait db.dropNamespace([\"prod\"], { mode: \"skip\" });\n";
|
||||
|
||||
export const TsNamespaceTableOps = "const db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\n\n// Create namespace tree: prod/search and prod/recommendations\nawait db.createNamespace([\"prod\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"search\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"recommendations\"], { mode: \"exist_ok\" });\n\nawait db.createTable(\n \"user\",\n [{ id: 1, vector: [0.1, 0.2], name: \"alice\" }],\n [\"prod\", \"search\"],\n { mode: \"create\" }, // use \"overwrite\" only if you want to replace existing table\n);\n\nawait db.createTable(\n \"user\",\n [{ id: 2, vector: [0.3, 0.4], name: \"bob\" }],\n [\"prod\", \"recommendations\"],\n { mode: \"create\" },\n);\n\n// Verify\nconsole.log((await db.listNamespaces()).namespaces); // [\"prod\"]\nconsole.log((await db.listNamespaces([\"prod\"])).namespaces); // [\"recommendations\", \"search\"]\nconsole.log(await db.tableNames([\"prod\", \"search\"])); // [\"user\"]\nconsole.log(await db.tableNames([\"prod\", \"recommendations\"])); // [\"user\"]\n";
|
||||
export const TsNamespaceTableOps = "async function collectNames(items: AsyncIterable<string>): Promise<string[]> {\n const names: string[] = [];\n for await (const name of items) names.push(name);\n return names;\n}\n\nconst db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\n\n// Create namespace tree: prod/search and prod/recommendations\nawait db.createNamespace([\"prod\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"search\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"recommendations\"], { mode: \"exist_ok\" });\n\nawait db.createTable(\n \"user\",\n [{ id: 1, vector: [0.1, 0.2], name: \"alice\" }],\n [\"prod\", \"search\"],\n { mode: \"create\" }, // use \"overwrite\" only if you want to replace existing table\n);\n\nawait db.createTable(\n \"user\",\n [{ id: 2, vector: [0.3, 0.4], name: \"bob\" }],\n [\"prod\", \"recommendations\"],\n { mode: \"create\" },\n);\n\n// Verify\nconsole.log(await collectNames(db.listNamespaces())); // [\"prod\"]\nconsole.log(await collectNames(db.listNamespaces([\"prod\"]))); // [\"recommendations\", \"search\"]\nconsole.log(await db.tableNames([\"prod\", \"search\"])); // [\"user\"]\nconsole.log(await db.tableNames([\"prod\", \"recommendations\"])); // [\"user\"]\n";
|
||||
|
||||
export const RsConnect = "let db = connect(\"ex_lancedb\").execute().await?;\n";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const PyFunctionClassApply = "from lancedb import col\n\napplication = em
|
||||
|
||||
export const PyFunctionClassDefine = "@udf(pip=[\"sentence-transformers\"])\nclass embed:\n def __init__(self, model: str = \"all-MiniLM-L6-v2\"):\n # Runs on the worker, once per instance: only the worker needs the package.\n from sentence_transformers import SentenceTransformer\n\n self.model = SentenceTransformer(model)\n\n def __call__(self, text: str) -> list[float]:\n return self.model.encode(text).tolist()\n\n def close(self):\n del self.model\n";
|
||||
|
||||
export const PyFunctionConnect = "import os\n\nimport lancedb\n\ndb = lancedb.connect(\n uri=os.environ[\"LANCEDB_URI\"], # db://your-database\n api_key=os.environ[\"LANCEDB_API_KEY\"],\n region=os.environ.get(\"LANCEDB_REGION\", \"us-east-1\"),\n host_override=os.environ[\"LANCEDB_HOST_OVERRIDE\"],\n)\n";
|
||||
export const PyFunctionConnect = "import os\n\nimport lancedb\n\ndb = lancedb.connect(\n uri=os.environ[\"LANCEDB_URI\"], # db://your-database\n api_key=os.environ[\"LANCEDB_API_KEY\"],\n region=os.environ.get(\"LANCEDB_REGION\", \"us-east-1\"),\n host_override=os.environ[\"LANCEDB_HOST_OVERRIDE\"],\n sql_host_override=os.environ[\"LANCEDB_SQL_HOST_OVERRIDE\"],\n)\n";
|
||||
|
||||
export const PyFunctionCreateTable = "import pyarrow as pa\n\nschema = pa.schema([pa.field(\"value\", pa.float64(), nullable=False)])\ntable = db.create_table(\n \"function_demo\",\n [{\"value\": 1.0}, {\"value\": 2.0}, {\"value\": 3.0}],\n schema=schema,\n)\n";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Function"
|
||||
sidebarTitle: "Function"
|
||||
description: "CREATE FUNCTION, SHOW FUNCTIONS, and DROP FUNCTION."
|
||||
description: "Create, describe, alter, list, and drop registered functions with SQL."
|
||||
icon: function
|
||||
keywords: ["create function", "drop function", "show functions", "sql", "udf"]
|
||||
---
|
||||
@@ -60,6 +60,35 @@ version is unaffected.
|
||||
the function is baked into an image before the version becomes usable.
|
||||
</Note>
|
||||
|
||||
## Artifact registration {#artifact}
|
||||
|
||||
`CREATE FUNCTION name FROM '<registration JSON>'` accepts the artifact,
|
||||
signature, runtime, optional secret bindings, and result stability produced by
|
||||
the SDK's registration request. Omit its `name` property because the SQL
|
||||
identifier supplies it. Escape apostrophes in the JSON as doubled apostrophes.
|
||||
|
||||
By default, the command waits for its internal image-build job to publish the
|
||||
version, then returns one UTF-8 `definition` column containing FunctionVersion
|
||||
JSON. `WITH (wait = false)` instead returns the existing image-build registry
|
||||
job ID in `job_id`, with `operation` and `status` columns. The SDK's ordinary
|
||||
registration handle has type `query` and uses the SQL query UUID as its single
|
||||
`id`; the internal image-build job has type `create_function`.
|
||||
|
||||
## DESCRIBE and ALTER FUNCTION {#describe-alter}
|
||||
|
||||
```sql
|
||||
DESCRIBE FUNCTION embed WITH (version = '1', include_source = true)
|
||||
```
|
||||
|
||||
DESCRIBE returns one `definition` JSON column for the exact version.
|
||||
`include_source` defaults to false; source-inclusive reads report an omission
|
||||
when the stored artifact exceeds the inline source limit.
|
||||
|
||||
`ALTER FUNCTION name FROM '<pinned update JSON>'` accepts a `reference` with
|
||||
name, namespace, object ID, location, version, and manifest digest, plus optional
|
||||
`metadata` and `disabled` updates. The command checks that reference before
|
||||
appending a revision and returns the resulting `definition` JSON.
|
||||
|
||||
## Call it {#call}
|
||||
|
||||
Once registered, a function is callable by name in any query, the same as a
|
||||
@@ -78,29 +107,27 @@ from it.
|
||||
|
||||
```sql
|
||||
SHOW FUNCTIONS
|
||||
SHOW FUNCTIONS IN "analytics" WITH (name = 'embed', limit = 100)
|
||||
SHOW FUNCTIONS WITH (after_name = 'embed', after_version = '1', limit = 100)
|
||||
```
|
||||
|
||||
Lists registered functions and their versions. Since a version is immutable
|
||||
and a column pins the version that fills it, this is how you find which
|
||||
versions are still in use before dropping one.
|
||||
Rows are ordered lexicographically by name and version. `after_name` and
|
||||
`after_version` must be supplied together for keyset pagination. `limit` bounds
|
||||
the response; `include_definition = false` omits the optional definition JSON.
|
||||
|
||||
## DROP FUNCTION {#drop}
|
||||
|
||||
```sql
|
||||
DROP FUNCTION embed WITH (version = '1')
|
||||
DROP FUNCTION IF EXISTS embed WITH (version = '1')
|
||||
DROP FUNCTION embed WITH (version = '1', cascade = 'true')
|
||||
```
|
||||
|
||||
`version` is required: a function is a set of immutable versions, and
|
||||
dropping is always of one specific version, never "the function" as a whole.
|
||||
`version` must identify an existing revision. DROP releases the function name
|
||||
and its object binding; physical cleanup continues separately. The result
|
||||
reports `dropped` or `not_found`. `IF EXISTS` allows a missing name.
|
||||
|
||||
<Warning>
|
||||
Without `cascade = 'true'`, dropping a version that a column still pins fails
|
||||
rather than breaking that column silently. Passing it drops the version and
|
||||
lets columns pinned to it stop refreshing. They keep their last-filled
|
||||
values, per [Guarantees](/basics/guarantees#jobs).
|
||||
</Warning>
|
||||
All function object commands retain shared results even when general query-result
|
||||
copying is disabled, so asynchronous polling can resume on another query node.
|
||||
|
||||
## What to read next {#next}
|
||||
|
||||
|
||||
+17
-16
@@ -975,7 +975,8 @@ class DBConnection(EnforceOverrides):
|
||||
"""Submit a scalar Python UDF for building and registration.
|
||||
|
||||
The server-side job builds the OCI image, then registers the completed
|
||||
artifact. Waiting on the job returns the immutable Function version.
|
||||
artifact. The returned job has type ``query`` and the SQL query UUID as
|
||||
its ID. Waiting on it returns the immutable Function version.
|
||||
LanceDB Embedded connections raise ``NotImplementedError``.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
@@ -1033,10 +1034,10 @@ class DBConnection(EnforceOverrides):
|
||||
"""Drop a Function name and the object it was bound to.
|
||||
|
||||
The requested version must exist in the currently named object. Returns
|
||||
True when the name was removed and False when it was absent. The
|
||||
object's content is deleted with it, which may finish after this
|
||||
returns; use :meth:`drop_function_async` to wait for that. Local
|
||||
connections raise NotImplementedError.
|
||||
True when the catalog binding was removed and False when it was absent.
|
||||
Physical cleanup continues in the background; use
|
||||
:meth:`drop_function_async` to access the query job. Local connections
|
||||
raise NotImplementedError.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Function catalog operations are not supported for this connection type"
|
||||
@@ -1049,13 +1050,12 @@ class DBConnection(EnforceOverrides):
|
||||
version: str,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Tuple[bool, Job]:
|
||||
"""Drop a Function name and return its cleanup job.
|
||||
"""Drop a Function binding and return its SQL query job.
|
||||
|
||||
The name is unbound before this returns; the object's content may still
|
||||
be being deleted. Call :meth:`Job.wait` to wait for that to finish. When
|
||||
the server deletes inline, or when nothing was bound, the returned job
|
||||
is already finished and has no id. LanceDB Embedded connections raise
|
||||
NotImplementedError.
|
||||
The boolean reports catalog removal. The job has type ``query`` and its
|
||||
ID is the SQL query UUID. Repeated waits confirm that completion;
|
||||
physical cleanup continues in the background. LanceDB Embedded
|
||||
connections raise NotImplementedError.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Function catalog operations are not supported for this connection type"
|
||||
@@ -3277,7 +3277,8 @@ class AsyncConnection(object):
|
||||
"""Submit a scalar Python UDF for building and registration.
|
||||
|
||||
The server-side job builds the OCI image, then registers the completed
|
||||
artifact. Waiting on the job returns the immutable Function version.
|
||||
artifact. The returned job has type ``query`` and the SQL query UUID as
|
||||
its ID. Waiting on it returns the immutable Function version.
|
||||
``secrets`` is a sequence of
|
||||
[EnvVarSecret][lancedb.secrets.EnvVarSecret], each naming a Secret and
|
||||
the environment variable its value arrives in. ``namespace_path`` is
|
||||
@@ -3347,11 +3348,11 @@ class AsyncConnection(object):
|
||||
version: str,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Tuple[bool, AsyncJob]:
|
||||
"""Drop a Function name and return its cleanup job.
|
||||
"""Drop a Function binding and return its SQL query job.
|
||||
|
||||
The name is unbound before this returns; the object's content may still
|
||||
be being deleted. Await :meth:`AsyncJob.wait` to wait for that to
|
||||
finish.
|
||||
The boolean reports catalog removal. The job has type ``query`` and its
|
||||
ID is the SQL query UUID. Repeated waits confirm that completion;
|
||||
physical cleanup continues in the background.
|
||||
"""
|
||||
dropped, job = await self._inner.drop_function_async(
|
||||
name, version, namespace_path
|
||||
|
||||
@@ -245,36 +245,6 @@ def test_bindings_may_not_collide_with_plain_configuration():
|
||||
)
|
||||
|
||||
|
||||
def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
|
||||
"""Binding rules are the service's: it owns the runtime the names land in.
|
||||
|
||||
The client sends what it was given, so a rule it duplicated could disagree
|
||||
with the service's without either side noticing. What is checked here is
|
||||
that the envelope arrives intact -- the shape the service judges is the
|
||||
shape the caller wrote.
|
||||
"""
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
bindings = [
|
||||
EnvVarSecret(secret_name=f"secret-{index}", env_variable=f"TOKEN_{index}")
|
||||
for index in range(17)
|
||||
]
|
||||
db.create_function(normalize_score, secrets=bindings)
|
||||
|
||||
sent = state["requests"][0][1]
|
||||
assert len(sent["secret_bindings"]) == 17
|
||||
assert {
|
||||
"kind": "env",
|
||||
"variable": "TOKEN_0",
|
||||
"secret_ref": {"name": "secret-0"},
|
||||
} in sent["secret_bindings"]
|
||||
|
||||
|
||||
_SECRET_DEBUG_LOG_SOURCE = """
|
||||
import http.server
|
||||
import json
|
||||
@@ -1846,80 +1816,6 @@ def _mock_remote_function_catalog():
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_remote_registration_job_and_exact_version_reopen_round_trip():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
registration = db.create_function_async(normalize_score)
|
||||
assert registration.id == "job-register"
|
||||
assert registration.job_type is None
|
||||
assert registration.status() == "finished"
|
||||
assert registration.job_type == "create_function"
|
||||
created = registration.wait()
|
||||
assert registration.job_type == "create_function"
|
||||
reopened = db.get_function("normalize_score", version=created.version)
|
||||
|
||||
assert created == reopened
|
||||
assert reopened.name == "normalize_score"
|
||||
assert reopened.version == FUNCTION_VERSION
|
||||
assert (
|
||||
reopened.source_digest == normalize_score.registration_request.artifact.digest
|
||||
)
|
||||
create_request = state["requests"][0][1]
|
||||
expected_request = json.loads(
|
||||
normalize_score.registration_request.to_canonical_json()
|
||||
)
|
||||
expected_request.pop("name")
|
||||
assert create_request == expected_request
|
||||
|
||||
|
||||
def test_remote_registration_sends_bindings_and_never_a_credential():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
created = db.create_function(
|
||||
analyze_caption,
|
||||
secrets=[
|
||||
EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
],
|
||||
)
|
||||
|
||||
assert list(created.secret_bindings) == [
|
||||
SecretBinding(
|
||||
kind="env",
|
||||
variable="OPENAI_API_KEY",
|
||||
secret_ref=SecretReference(name="openai-prod"),
|
||||
)
|
||||
]
|
||||
path, create_request = state["requests"][0]
|
||||
assert path == "/v1/function/analyze_caption/create"
|
||||
assert create_request["secret_bindings"] == [
|
||||
{
|
||||
"kind": "env",
|
||||
"variable": "OPENAI_API_KEY",
|
||||
"secret_ref": {"name": "openai-prod"},
|
||||
}
|
||||
]
|
||||
# The request names a Secret and carries nothing that could be one. The
|
||||
# Function's own name is the path identifier rather than a body field, so
|
||||
# it is the one key the body does not repeat.
|
||||
expected = json.loads(
|
||||
analyze_caption.bind_secrets(
|
||||
[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
).to_canonical_json()
|
||||
)
|
||||
assert expected.pop("name") == "analyze_caption"
|
||||
assert create_request == expected
|
||||
|
||||
|
||||
def test_remote_secret_verbs_round_trip():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
@@ -1963,7 +1859,7 @@ def test_building_a_binding_contacts_no_server():
|
||||
assert state["requests"] == []
|
||||
|
||||
|
||||
def test_registered_vectorized_blob_udf_hydrates_executes_and_publishes(tmp_path):
|
||||
def test_vectorized_blob_udf_executes_packaged_artifact_and_publishes(tmp_path):
|
||||
@udf(
|
||||
input_schema=pa.schema([lancedb.blob("image", nullable=True)]),
|
||||
output_schema=lancedb.blob("copy", nullable=False),
|
||||
@@ -1978,19 +1874,7 @@ def test_registered_vectorized_blob_udf_hydrates_executes_and_publishes(tmp_path
|
||||
type=pa.large_binary(),
|
||||
)
|
||||
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
remote = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
remote.create_function(copy_blobs)
|
||||
registration = state["requests"][0][1]
|
||||
|
||||
definition = json.loads(copy_blobs.registration_request.to_canonical_json())
|
||||
assert registration["artifact"] == definition["artifact"]
|
||||
assert registration["signature"] == definition["signature"]
|
||||
registration = json.loads(copy_blobs.registration_request.to_canonical_json())
|
||||
|
||||
local = lancedb.connect(tmp_path)
|
||||
source = local.create_table(
|
||||
@@ -2049,196 +1933,6 @@ def test_registered_vectorized_blob_udf_hydrates_executes_and_publishes(tmp_path
|
||||
]
|
||||
|
||||
|
||||
def test_blocking_remote_registration_returns_function_version():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
created = db.create_function(normalize_score)
|
||||
|
||||
assert created.name == "normalize_score"
|
||||
assert created.version == FUNCTION_VERSION
|
||||
assert created.source_digest == normalize_score.registration_request.artifact.digest
|
||||
assert [path for path, _ in state["requests"]] == [
|
||||
"/v1/function/normalize_score/create",
|
||||
"/v1/jobs/describe",
|
||||
]
|
||||
|
||||
|
||||
def test_remote_list_functions_paginates_and_returns_typed_versions():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
created = db.create_function(normalize_score)
|
||||
state["requests"].clear()
|
||||
functions = list(db.list_functions())
|
||||
|
||||
assert functions == [created]
|
||||
assert functions[0].source_digest == (
|
||||
normalize_score.registration_request.artifact.digest
|
||||
)
|
||||
assert state["requests"] == [
|
||||
("/v1/namespace/$/function/list", {"include_definition": "true"}),
|
||||
(
|
||||
"/v1/namespace/$/function/list",
|
||||
{"include_definition": "true", "page_token": "next"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_list_functions_returns_typed_versions():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = await lancedb.connect_async(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
registration = await db.create_function_async(normalize_score)
|
||||
created = await registration.wait()
|
||||
state["requests"].clear()
|
||||
functions = [item async for item in db.list_functions()]
|
||||
|
||||
assert functions == [created]
|
||||
assert functions[0].source_digest == (
|
||||
normalize_score.registration_request.artifact.digest
|
||||
)
|
||||
assert [path for path, _ in state["requests"]] == [
|
||||
"/v1/namespace/$/function/list",
|
||||
"/v1/namespace/$/function/list",
|
||||
]
|
||||
|
||||
|
||||
def test_remote_drop_function_sends_exact_version():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
assert db.drop_function("normalize_score", version=FUNCTION_VERSION) is True
|
||||
|
||||
assert state["requests"] == [
|
||||
(
|
||||
"/v1/function/normalize_score/drop",
|
||||
{"version": FUNCTION_VERSION},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_drop_function_sends_exact_version():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = await lancedb.connect_async(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
assert (
|
||||
await db.drop_function("normalize_score", version=FUNCTION_VERSION) is True
|
||||
)
|
||||
|
||||
assert state["requests"] == [
|
||||
(
|
||||
"/v1/function/normalize_score/drop",
|
||||
{"version": FUNCTION_VERSION},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_remote_function_verbs_address_a_namespace_in_the_path():
|
||||
"""A Function's namespace is part of the identifier each route addresses;
|
||||
no body carries it, so a namespaced request differs only in its path."""
|
||||
namespace = ["analytics", "features"]
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
created = db.create_function(normalize_score, namespace_path=namespace)
|
||||
# The returned parts are what the other verbs take, so a created
|
||||
# Function reopens from its own fields.
|
||||
assert created.name == "normalize_score"
|
||||
assert created.namespace_path == tuple(namespace)
|
||||
reopened = db.get_function(
|
||||
created.name,
|
||||
version=created.version,
|
||||
namespace_path=list(created.namespace_path),
|
||||
)
|
||||
assert reopened == created
|
||||
assert reopened(value=lancedb.col("score")).function.namespace_path == tuple(
|
||||
namespace
|
||||
)
|
||||
list(db.list_functions(namespace_path=namespace))
|
||||
assert (
|
||||
db.drop_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
)
|
||||
is True
|
||||
)
|
||||
dropped, _ = db.drop_function_async(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
)
|
||||
assert dropped is True
|
||||
|
||||
assert [path for path, _ in state["requests"]] == [
|
||||
"/v1/function/analytics$features$normalize_score/create",
|
||||
"/v1/jobs/describe",
|
||||
"/v1/function/analytics$features$normalize_score/describe",
|
||||
"/v1/namespace/analytics$features/function/list",
|
||||
"/v1/namespace/analytics$features/function/list",
|
||||
"/v1/function/analytics$features$normalize_score/drop",
|
||||
"/v1/function/analytics$features$normalize_score/drop",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_function_verbs_address_a_namespace_in_the_path():
|
||||
namespace = ["analytics", "features"]
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = await lancedb.connect_async(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
registration = await db.create_function_async(
|
||||
normalize_score, namespace_path=namespace
|
||||
)
|
||||
await registration.wait()
|
||||
await db.get_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
)
|
||||
[item async for item in db.list_functions(namespace_path=namespace)]
|
||||
assert (
|
||||
await db.drop_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert [path for path, _ in state["requests"]] == [
|
||||
"/v1/function/analytics$features$normalize_score/create",
|
||||
"/v1/jobs/describe",
|
||||
"/v1/function/analytics$features$normalize_score/describe",
|
||||
"/v1/namespace/analytics$features/function/list",
|
||||
"/v1/namespace/analytics$features/function/list",
|
||||
"/v1/function/analytics$features$normalize_score/drop",
|
||||
]
|
||||
|
||||
|
||||
def test_a_function_namespace_path_must_be_a_list_of_segments():
|
||||
"""A bare string is refused rather than read as a sequence of one-character
|
||||
segments, and nothing reaches the service."""
|
||||
|
||||
@@ -84,7 +84,7 @@ async def advance(items, asynchronous):
|
||||
return await items.__anext__() if asynchronous else next(items)
|
||||
|
||||
|
||||
KINDS = ["secrets", "views", "jobs", "functions"]
|
||||
KINDS = ["secrets", "views", "jobs"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -646,9 +646,10 @@ impl Connection {
|
||||
/// in `namespace_path`, which is empty for the root namespace.
|
||||
///
|
||||
/// The server-side job builds the OCI image, then registers the completed
|
||||
/// artifact. Waiting on the returned typed job yields the durable
|
||||
/// [`crate::function::FunctionVersion`]. Creation is supported only in LanceDB Distributed.
|
||||
/// LanceDB Embedded databases return [`Error::NotSupported`].
|
||||
/// artifact. The returned job has type `query` and the SQL query UUID as its
|
||||
/// ID. Waiting on the returned typed job yields the durable
|
||||
/// [`crate::function::FunctionVersion`]. Creation is remote-only.
|
||||
/// Local databases return [`Error::NotSupported`].
|
||||
///
|
||||
pub async fn create_function_async(
|
||||
&self,
|
||||
@@ -730,12 +731,11 @@ impl Connection {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start dropping a Function and return its cleanup job.
|
||||
/// Drop a Function binding and return its SQL query handle.
|
||||
///
|
||||
/// The name is unbound before this returns; the object's content may still be being
|
||||
/// deleted. Await [`Job::wait`][crate::job::Job::wait] to wait for that to finish. When
|
||||
/// the server deletes inline, or when nothing was bound, the returned job is already
|
||||
/// finished and has no id. LanceDB Embedded databases return [`Error::NotSupported`].
|
||||
/// The boolean reports catalog removal. The query job retains its query ID,
|
||||
/// and repeated waits confirm completion. Physical cleanup continues in the
|
||||
/// background. Local databases return [`Error::NotSupported`].
|
||||
pub async fn drop_function_async(
|
||||
&self,
|
||||
name: impl AsRef<str>,
|
||||
|
||||
@@ -460,10 +460,9 @@ pub trait Database:
|
||||
) -> Result<bool> {
|
||||
function_catalog_not_supported()
|
||||
}
|
||||
/// Start dropping a Function and return a handle to the cleanup job.
|
||||
///
|
||||
/// Backends without asynchronous cleanup complete the drop before returning an
|
||||
/// already-finished job.
|
||||
/// Drop a Function binding and return its completion handle.
|
||||
/// SQL backends return a query job confirming catalog removal; physical
|
||||
/// cleanup continues separately.
|
||||
async fn drop_function_async(
|
||||
&self,
|
||||
name: &str,
|
||||
|
||||
+36
-510
@@ -25,16 +25,12 @@ use crate::database::{
|
||||
ExecuteQueryRequest, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest,
|
||||
};
|
||||
use crate::error::Result;
|
||||
use crate::function::{
|
||||
FunctionArtifactRequest, FunctionRegistrationRequest, FunctionSignature, FunctionVersion,
|
||||
PythonRuntimeSpec,
|
||||
};
|
||||
use crate::function::{FunctionRegistrationRequest, FunctionVersion};
|
||||
use crate::job::Job;
|
||||
use crate::listing::{ListingOptions, ListingPage};
|
||||
use crate::materialized_view::CreateMaterializedViewRequest;
|
||||
use crate::remote::job::{PauseJobResponse, RemoteJob, ResumeJobResponse, job_state_to_client};
|
||||
use crate::remote::util::stream_as_body;
|
||||
use crate::secrets::SecretBinding;
|
||||
use crate::secrets::SecretInfo;
|
||||
use crate::table::BaseTable;
|
||||
use crate::utils::{
|
||||
@@ -475,6 +471,12 @@ impl<S: HttpSend> RemoteDatabase<S> {
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_client(&self) -> Result<&SqlClient> {
|
||||
self.sql_client.as_ref().ok_or_else(|| Error::NotSupported {
|
||||
message: "SQL is unavailable for this remote database client".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Post a request whose body carries a credential.
|
||||
///
|
||||
/// Shared by the create and alter verbs, which declare their own request
|
||||
@@ -668,14 +670,6 @@ fn build_namespace_identifier(namespace: &[String]) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full path to a function. Note that we do not need to percent-encode
|
||||
/// this when using it in a URL because the validation functions ensure that
|
||||
/// only URL-safe characters are present.
|
||||
fn build_function_identifier(name: &str, namespace: &[String]) -> Result<String> {
|
||||
validate_function_name(name)?;
|
||||
build_compound_identifier(name, namespace)
|
||||
}
|
||||
|
||||
/// Build a full path to a secret. Note that we do not need to percent-encode
|
||||
/// this when using it in a URL because the validation functions ensure that
|
||||
/// only URL-safe characters are present.
|
||||
@@ -736,49 +730,6 @@ struct RemoteListJobsResponse {
|
||||
page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RemoteListedFunctionVersion {
|
||||
definition: FunctionVersion,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RemoteListFunctionsResponse {
|
||||
#[serde(default)]
|
||||
functions: Vec<RemoteListedFunctionVersion>,
|
||||
#[serde(default)]
|
||||
page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RemoteDropFunctionResponse {
|
||||
dropped: bool,
|
||||
}
|
||||
|
||||
/// The create body: every field of [`FunctionRegistrationRequest`] except the
|
||||
/// name, which is the path identifier.
|
||||
///
|
||||
/// A struct rather than a literal listing the fields, so that the compiler
|
||||
/// decides what reaches the service. A field the registration request grows is
|
||||
/// a build error here until it is handled; a literal would simply not send it.
|
||||
#[derive(serde::Serialize)]
|
||||
struct RemoteCreateFunctionRequest<'a> {
|
||||
artifact: &'a FunctionArtifactRequest,
|
||||
signature: &'a FunctionSignature,
|
||||
runtime: &'a PythonRuntimeSpec,
|
||||
/// Absent when the Function binds nothing, so such a client sends what a
|
||||
/// client without bindings sends.
|
||||
///
|
||||
/// A service that does not know the field ignores it: registration
|
||||
/// succeeds, the returned version carries no bindings, and the Function
|
||||
/// fails at execution with the variable unset. [`ServerVersion`] is how
|
||||
/// this codebase refuses a feature the service is too old for; it is held
|
||||
/// per table, so gating a database-level call is follow-up work.
|
||||
///
|
||||
/// [`ServerVersion`]: super::db::ServerVersion
|
||||
#[serde(skip_serializing_if = "<[SecretBinding]>::is_empty")]
|
||||
secret_bindings: &'a [SecretBinding],
|
||||
}
|
||||
|
||||
/// Create a Secret under a name the database does not yet hold.
|
||||
///
|
||||
/// Declared separately from the alter request although the two are identical
|
||||
@@ -976,29 +927,11 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
request: FunctionRegistrationRequest,
|
||||
namespace_path: &[String],
|
||||
) -> Result<Job<FunctionVersion>> {
|
||||
let function_id = build_function_identifier(&request.name, namespace_path)?;
|
||||
let req = self
|
||||
.client
|
||||
.post(&format!("/v1/function/{function_id}/create"))
|
||||
.json(&RemoteCreateFunctionRequest {
|
||||
artifact: &request.artifact,
|
||||
signature: &request.signature,
|
||||
runtime: &request.runtime,
|
||||
secret_bindings: &request.secret_bindings,
|
||||
});
|
||||
let (request_id, response) = self.client.send(req).await?;
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.err_to_http(request_id.clone())?;
|
||||
let job_id = extract_job_id(&body).ok_or_else(|| Error::Http {
|
||||
source: "Function registration response did not contain a valid job_id".into(),
|
||||
request_id,
|
||||
status_code: Some(status),
|
||||
})?;
|
||||
Ok(Job::new_typed(Box::new(RemoteJob::new(
|
||||
self.client.clone(),
|
||||
job_id,
|
||||
))))
|
||||
validate_function_name(&request.name)?;
|
||||
validate_namespace(namespace_path)?;
|
||||
self.sql_client()?
|
||||
.register_function(request, namespace_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_function(
|
||||
@@ -1007,46 +940,22 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
version: &str,
|
||||
namespace_path: &[String],
|
||||
) -> Result<FunctionVersion> {
|
||||
let function_id = build_function_identifier(name, namespace_path)?;
|
||||
let req = self
|
||||
.client
|
||||
.post(&format!("/v1/function/{function_id}/describe"))
|
||||
.json(&serde_json::json!({
|
||||
"version": version,
|
||||
}));
|
||||
let (request_id, response) = self.client.send(req).await?;
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
response.json().await.err_to_http(request_id)
|
||||
validate_function_name(name)?;
|
||||
validate_namespace(namespace_path)?;
|
||||
self.sql_client()?
|
||||
.describe_function(name, version, namespace_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_functions(
|
||||
&self,
|
||||
namespace_path: &[String],
|
||||
options: ListingOptions,
|
||||
) -> Result<ListingPage<crate::function::FunctionVersion>> {
|
||||
let namespace_id = build_namespace_identifier(namespace_path)?;
|
||||
let path = format!("/v1/namespace/{namespace_id}/function/list");
|
||||
let mut req = self.client.get(&path);
|
||||
if let Some(token) = options.page_token {
|
||||
req = req.query(&[("page_token", token)]);
|
||||
}
|
||||
if let Some(limit) = options.page_limit {
|
||||
req = req.query(&[("limit", limit)]);
|
||||
}
|
||||
req = req.query(&[("include_definition", true)]);
|
||||
let (request_id, response) = self.client.send(req).await?;
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
let response: RemoteListFunctionsResponse =
|
||||
response.json().await.err_to_http(request_id)?;
|
||||
let items = response
|
||||
.functions
|
||||
.into_iter()
|
||||
.map(|listed| listed.definition)
|
||||
.collect();
|
||||
Ok(ListingPage {
|
||||
items,
|
||||
page_token: response.page_token,
|
||||
})
|
||||
) -> Result<ListingPage<FunctionVersion>> {
|
||||
validate_namespace(namespace_path)?;
|
||||
self.sql_client()?
|
||||
.list_functions(namespace_path, options)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn drop_function(
|
||||
@@ -1055,10 +964,14 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
version: &str,
|
||||
namespace_path: &[String],
|
||||
) -> Result<bool> {
|
||||
Ok(self
|
||||
.drop_function_async(name, version, namespace_path)
|
||||
.await?
|
||||
.0)
|
||||
validate_function_name(name)?;
|
||||
validate_namespace(namespace_path)?;
|
||||
let (dropped, job) = self
|
||||
.sql_client()?
|
||||
.drop_function(name, version, namespace_path)
|
||||
.await?;
|
||||
job.wait().await?;
|
||||
Ok(dropped)
|
||||
}
|
||||
|
||||
async fn drop_function_async(
|
||||
@@ -1067,43 +980,11 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
version: &str,
|
||||
namespace_path: &[String],
|
||||
) -> Result<(bool, Job)> {
|
||||
let function_id = build_function_identifier(name, namespace_path)?;
|
||||
let req = self
|
||||
.client
|
||||
.post(&format!("/v1/function/{function_id}/drop"))
|
||||
.json(&serde_json::json!({
|
||||
"version": version,
|
||||
}));
|
||||
let (request_id, response) = self.client.send(req).await?;
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.err_to_http(request_id.clone())?;
|
||||
let dropped: RemoteDropFunctionResponse =
|
||||
serde_json::from_str(&body).map_err(|source| Error::Http {
|
||||
source: Box::new(source),
|
||||
request_id: request_id.clone(),
|
||||
status_code: Some(status),
|
||||
})?;
|
||||
let job = match status {
|
||||
StatusCode::OK => Job::new_done(),
|
||||
StatusCode::ACCEPTED => {
|
||||
let job_id = extract_job_id(&body).ok_or_else(|| Error::Http {
|
||||
source: "asynchronous Function drop response did not contain a valid job_id"
|
||||
.into(),
|
||||
request_id,
|
||||
status_code: Some(status),
|
||||
})?;
|
||||
Job::new(Box::new(RemoteJob::new(self.client.clone(), job_id)))
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::Http {
|
||||
source: "Function drop must return 200 OK or 202 Accepted".into(),
|
||||
request_id,
|
||||
status_code: Some(status),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok((dropped.dropped, job))
|
||||
validate_function_name(name)?;
|
||||
validate_namespace(namespace_path)?;
|
||||
self.sql_client()?
|
||||
.drop_function(name, version, namespace_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_storage_integration(
|
||||
@@ -2142,57 +2023,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_function_async_returns_job() {
|
||||
let db = super::RemoteDatabase::new_mock(|request| {
|
||||
assert_eq!(request.method(), "POST");
|
||||
assert_eq!(request.url().path(), "/v1/function/embed/drop");
|
||||
http::Response::builder()
|
||||
.status(202)
|
||||
.body(serde_json::json!({"dropped": true, "job_id": "j1-fn-drop"}).to_string())
|
||||
.unwrap()
|
||||
});
|
||||
let (dropped, job) = db.drop_function_async("embed", "1", &[]).await.unwrap();
|
||||
assert!(dropped);
|
||||
assert_eq!(job.id(), Some("j1-fn-drop"));
|
||||
}
|
||||
|
||||
/// An unbound name and an inline deletion both answer `200`: the name is gone and
|
||||
/// nothing is left to wait for, so the job is already finished.
|
||||
#[tokio::test]
|
||||
async fn test_drop_function_async_completed_inline() {
|
||||
let db = super::RemoteDatabase::new_mock(|_| {
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(serde_json::json!({"dropped": false}).to_string())
|
||||
.unwrap()
|
||||
});
|
||||
let (dropped, job) = db.drop_function_async("embed", "1", &[]).await.unwrap();
|
||||
assert!(!dropped);
|
||||
assert_eq!(job.id(), None);
|
||||
assert_eq!(job.status().await.unwrap(), "finished");
|
||||
job.wait().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_function_async_rejects_incomplete_acceptance() {
|
||||
for body in [
|
||||
r#"{"dropped":true}"#,
|
||||
r#"{"dropped":true,"job_id":""}"#,
|
||||
r#"{"dropped":true,"job_id":null}"#,
|
||||
] {
|
||||
let db = super::RemoteDatabase::new_mock(move |_| {
|
||||
http::Response::builder().status(202).body(body).unwrap()
|
||||
});
|
||||
let error = db
|
||||
.drop_function_async("embed", "1", &[])
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("valid job_id"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retries() {
|
||||
// We'll record the request_id here, to check it matches the one in the error.
|
||||
@@ -3874,7 +3704,7 @@ mod tests {
|
||||
assert!(items.try_next().await.unwrap().is_none());
|
||||
assert!(items.try_next().await.unwrap().is_none());
|
||||
}
|
||||
for resource in ["secret", "view", "function", "jobs"] {
|
||||
for resource in ["secret", "view", "jobs"] {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let conn = Connection::new_with_handler({
|
||||
let count = count.clone();
|
||||
@@ -3897,9 +3727,6 @@ mod tests {
|
||||
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
|
||||
assert_eq!(query.get("page_token").unwrap(), token);
|
||||
assert_eq!(query.get("limit").unwrap(), "2");
|
||||
if resource == "function" {
|
||||
assert_eq!(query.get("include_definition").unwrap(), "true");
|
||||
}
|
||||
}
|
||||
let (key, item) = match resource {
|
||||
"secret" => ("secrets", serde_json::json!({"name": "a"})),
|
||||
@@ -3907,10 +3734,6 @@ mod tests {
|
||||
"jobs",
|
||||
serde_json::json!({"job_id": "a", "table": "t", "job_type": "create_index", "state": "in_progress", "created_at_millis": 1}),
|
||||
),
|
||||
"function" => (
|
||||
"functions",
|
||||
serde_json::json!({"name": "embed", "version": "1", "definition": serde_json::from_str::<serde_json::Value>(include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json")).unwrap()}),
|
||||
),
|
||||
_ => ("views", serde_json::json!("a")),
|
||||
};
|
||||
let items = if page == 1 {
|
||||
@@ -3928,7 +3751,6 @@ mod tests {
|
||||
match resource {
|
||||
"secret" => check(conn.list_secrets(&namespace, options), count).await,
|
||||
"view" => check(conn.list_views(&namespace, options), count).await,
|
||||
"function" => check(conn.list_functions(&namespace, options), count).await,
|
||||
_ => check(conn.list_jobs(options), count).await,
|
||||
}
|
||||
}
|
||||
@@ -4389,27 +4211,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Reject relative segments and their encoded forms before sending a request,
|
||||
/// so a function name cannot change the route through URL normalization.
|
||||
#[tokio::test]
|
||||
async fn test_a_relative_segment_function_name_is_refused() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
for name in [".", "..", "%2E%2E"] {
|
||||
let reached = Arc::new(Mutex::new(false));
|
||||
let flag = reached.clone();
|
||||
let conn = Connection::new_with_handler(move |_| {
|
||||
*flag.lock().unwrap() = true;
|
||||
http::Response::builder().status(200).body("{}").unwrap()
|
||||
});
|
||||
let error = conn
|
||||
.drop_function(name, "fv_1", &[])
|
||||
.await
|
||||
.expect_err("a dot-only Function name must be refused");
|
||||
assert!(matches!(error, Error::InvalidInput { .. }), "{error}");
|
||||
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
|
||||
}
|
||||
}
|
||||
|
||||
/// Only `.` and `..` are relative segments. `...` and longer runs are
|
||||
/// ordinary and address perfectly well, so refusing them would make an
|
||||
/// object that works today stop working on upgrade. This pins that.
|
||||
@@ -4792,281 +4593,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
|
||||
const REQUEST: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
|
||||
);
|
||||
const FUNCTION_JOB: &str =
|
||||
include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_job.json");
|
||||
let mut expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
|
||||
expected.as_object_mut().unwrap().remove("name");
|
||||
let conn = Connection::new_with_handler(move |request| match request.url().path() {
|
||||
"/v1/function/normalize_score/create" => {
|
||||
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(body, expected);
|
||||
http::Response::builder()
|
||||
.status(202)
|
||||
.body(r#"{"job_id":"job-function-1"}"#)
|
||||
.unwrap()
|
||||
}
|
||||
"/v1/jobs/describe" => http::Response::builder()
|
||||
.status(200)
|
||||
.body(FUNCTION_JOB)
|
||||
.unwrap(),
|
||||
path => panic!("unexpected path: {path}"),
|
||||
});
|
||||
let request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
|
||||
let job = conn.create_function_async(request, &[]).await.unwrap();
|
||||
assert_eq!(job.id(), Some("job-function-1"));
|
||||
let version = job.wait().await.unwrap();
|
||||
assert_eq!(version.name(), "embed");
|
||||
assert_eq!(version.version(), "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_function_requires_and_sends_exact_version() {
|
||||
const VERSION: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
|
||||
);
|
||||
let conn = Connection::new_with_handler(|request| {
|
||||
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||
assert_eq!(request.url().path(), "/v1/function/embed/describe");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!({"version": "1"}));
|
||||
http::Response::builder().status(200).body(VERSION).unwrap()
|
||||
});
|
||||
let version = conn.get_function("embed", "1", &[]).await.unwrap();
|
||||
assert_eq!(version.name(), "embed");
|
||||
assert_eq!(version.version(), "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_functions_requests_definitions_and_paginates() {
|
||||
const VERSION: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
|
||||
);
|
||||
let version: serde_json::Value = serde_json::from_str(VERSION).unwrap();
|
||||
let page = Arc::new(AtomicUsize::new(0));
|
||||
let conn = Connection::new_with_handler(move |request| {
|
||||
assert_eq!(request.method(), &reqwest::Method::GET);
|
||||
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
|
||||
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
|
||||
assert_eq!(query.get("include_definition").unwrap(), "true");
|
||||
match page.fetch_add(1, Ordering::SeqCst) {
|
||||
0 => {
|
||||
assert!(!query.contains_key("page_token"));
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"functions": [], "page_token": "next"}"#.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
_ => {
|
||||
assert_eq!(query.get("page_token").unwrap(), "next");
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"functions": [{
|
||||
"name": "embed",
|
||||
"version": "1",
|
||||
"definition": version.clone(),
|
||||
}],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
});
|
||||
let functions = conn
|
||||
.list_functions(&[], Default::default())
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(functions.len(), 1);
|
||||
assert_eq!(functions[0].name(), "embed");
|
||||
assert_eq!(functions[0].version(), "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_functions_stops_on_an_empty_page_token() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let seen = requests.clone();
|
||||
let conn = Connection::new_with_handler(move |request| {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(request.method(), &reqwest::Method::GET);
|
||||
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
|
||||
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
|
||||
assert_eq!(query.get("include_definition").unwrap(), "true");
|
||||
assert!(!query.contains_key("page_token"));
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"functions": [], "page_token": ""}"#)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let functions = conn
|
||||
.list_functions(&[], Default::default())
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(functions.is_empty());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_functions_rejects_a_page_token_cycle() {
|
||||
let page = Arc::new(AtomicUsize::new(0));
|
||||
let requests = page.clone();
|
||||
let conn = Connection::new_with_handler(move |request| {
|
||||
assert_eq!(request.method(), &reqwest::Method::GET);
|
||||
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
|
||||
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
|
||||
assert_eq!(query.get("include_definition").unwrap(), "true");
|
||||
let next_page_token = match page.fetch_add(1, Ordering::SeqCst) {
|
||||
0 => {
|
||||
assert!(!query.contains_key("page_token"));
|
||||
"one"
|
||||
}
|
||||
1 => {
|
||||
assert_eq!(query.get("page_token").unwrap(), "one");
|
||||
"two"
|
||||
}
|
||||
2 => {
|
||||
assert_eq!(query.get("page_token").unwrap(), "two");
|
||||
"one"
|
||||
}
|
||||
page => panic!("unexpected page: {page}"),
|
||||
};
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"functions": [],
|
||||
"page_token": next_page_token,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let mut items = conn.list_functions(&[], Default::default());
|
||||
let error = (&mut items).try_collect::<Vec<_>>().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, Error::Runtime { message } if message.contains("repeated a page_token")),
|
||||
"got {error:?}"
|
||||
);
|
||||
assert!(items.page_token().is_some());
|
||||
assert!(items.try_next().await.unwrap().is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_function_sends_exact_version_and_decodes_replay() {
|
||||
let conn = Connection::new_with_handler(|request| {
|
||||
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||
assert_eq!(request.url().path(), "/v1/function/embed/drop");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!({"version": "1"}));
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"dropped":false}"#)
|
||||
.unwrap()
|
||||
});
|
||||
assert!(!conn.drop_function("embed", "1", &[]).await.unwrap());
|
||||
}
|
||||
|
||||
/// A Function's namespace is addressed in the path the way a Secret's is:
|
||||
/// every route takes the joined identifier and no body carries the
|
||||
/// namespace, so a namespaced request differs from a root one only in its
|
||||
/// path.
|
||||
#[tokio::test]
|
||||
async fn test_a_function_namespace_path_is_addressed_in_the_path() {
|
||||
const REQUEST: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
|
||||
);
|
||||
const VERSION: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
|
||||
);
|
||||
let namespace = ["analytics".to_string(), "features".to_string()];
|
||||
let mut expected_create: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
|
||||
expected_create.as_object_mut().unwrap().remove("name");
|
||||
let conn = Connection::new_with_handler(move |request| {
|
||||
let body = request
|
||||
.body()
|
||||
.and_then(|body| body.as_bytes())
|
||||
.map(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).unwrap());
|
||||
match request.url().path() {
|
||||
"/v1/function/analytics$features$normalize_score/create" => {
|
||||
assert_eq!(body.unwrap(), expected_create);
|
||||
http::Response::builder()
|
||||
.status(202)
|
||||
.body(r#"{"job_id":"job-function-1"}"#.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
"/v1/function/analytics$features$embed/describe" => {
|
||||
assert_eq!(body.unwrap(), serde_json::json!({"version": "1"}));
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(VERSION.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
"/v1/function/analytics$features$embed/drop" => {
|
||||
assert_eq!(body.unwrap(), serde_json::json!({"version": "1"}));
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"dropped":true}"#.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
// Listing is namespace-scoped, so the namespace is the whole
|
||||
// identifier.
|
||||
"/v1/namespace/analytics$features/function/list" => http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"functions":[]}"#.to_string())
|
||||
.unwrap(),
|
||||
path => panic!("unexpected path: {path}"),
|
||||
}
|
||||
});
|
||||
let request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
|
||||
let job = conn
|
||||
.create_function_async(request, &namespace)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(job.id(), Some("job-function-1"));
|
||||
conn.get_function("embed", "1", &namespace).await.unwrap();
|
||||
assert!(
|
||||
conn.list_functions(&namespace, Default::default())
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(conn.drop_function("embed", "1", &namespace).await.unwrap());
|
||||
}
|
||||
|
||||
/// Each namespace segment is checked before a request is built, so an
|
||||
/// empty segment cannot collapse the identifier onto the parent namespace.
|
||||
#[tokio::test]
|
||||
async fn test_an_unaddressable_function_namespace_segment_is_refused() {
|
||||
let conn = Connection::new_with_handler(|request| -> http::Response<String> {
|
||||
panic!("reached the transport: {}", request.url().path())
|
||||
});
|
||||
let namespace = ["analytics".to_string(), String::new()];
|
||||
assert!(conn.get_function("embed", "1", &namespace).await.is_err());
|
||||
assert!(
|
||||
conn.list_functions(&namespace, Default::default())
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(conn.drop_function("embed", "1", &namespace).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_conn_job_waits_to_done() {
|
||||
let polls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -35,7 +35,9 @@ use crate::remote::client::{ClientConfig, TlsConfig};
|
||||
use crate::remote::retry::ResolvedRetryConfig;
|
||||
use crate::sql::{Query, QueryDescription, QueryHandle, QueryStatus};
|
||||
|
||||
mod function;
|
||||
pub(super) mod materialized_view;
|
||||
mod objects;
|
||||
mod stream;
|
||||
|
||||
use stream::{StreamingQuery, StreamingQueryHandle, Submission};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use super::{
|
||||
SqlClient,
|
||||
materialized_view::quote_identifier,
|
||||
objects::{definition, literal, strings},
|
||||
};
|
||||
use crate::{
|
||||
database::ExecuteQueryRequest,
|
||||
error::{Error, Result},
|
||||
function::{FunctionRegistrationRequest, FunctionVersion},
|
||||
job::Job,
|
||||
listing::{ListingOptions, ListingPage},
|
||||
utils::{validate_function_name, validate_namespace},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
impl SqlClient {
|
||||
pub(in crate::remote) async fn register_function(
|
||||
&self,
|
||||
request: FunctionRegistrationRequest,
|
||||
namespace: &[String],
|
||||
) -> Result<Job<FunctionVersion>> {
|
||||
validate_function_name(&request.name)?;
|
||||
validate_namespace(namespace)?;
|
||||
let name = quote_identifier(&request.name);
|
||||
let mut body = serde_json::to_value(request).map_err(|error| Error::Runtime {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
body.as_object_mut().unwrap().remove("name");
|
||||
self.object_job(
|
||||
ExecuteQueryRequest {
|
||||
query: format!("CREATE FUNCTION {name} FROM {}", literal(&body.to_string())),
|
||||
default_namespace_path: namespace.to_vec(),
|
||||
parameters: None,
|
||||
},
|
||||
definition,
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub(in crate::remote) async fn describe_function(
|
||||
&self,
|
||||
name: &str,
|
||||
version: &str,
|
||||
namespace: &[String],
|
||||
) -> Result<FunctionVersion> {
|
||||
validate_function_name(name)?;
|
||||
validate_namespace(namespace)?;
|
||||
self.object_job(
|
||||
ExecuteQueryRequest {
|
||||
query: format!(
|
||||
"DESCRIBE FUNCTION {} WITH (version = {})",
|
||||
quote_identifier(name),
|
||||
literal(version)
|
||||
),
|
||||
default_namespace_path: namespace.to_vec(),
|
||||
parameters: None,
|
||||
},
|
||||
definition,
|
||||
)
|
||||
.await?
|
||||
.wait()
|
||||
.await
|
||||
}
|
||||
pub(in crate::remote) async fn list_functions(
|
||||
&self,
|
||||
namespace: &[String],
|
||||
options: ListingOptions,
|
||||
) -> Result<ListingPage<FunctionVersion>> {
|
||||
validate_namespace(namespace)?;
|
||||
let limit = options.page_limit.unwrap_or(100);
|
||||
if limit == 0 || limit > i32::MAX as u32 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "page limit must be between 1 and i32::MAX".into(),
|
||||
});
|
||||
}
|
||||
let invalid = || Error::InvalidInput {
|
||||
message: "invalid function page token for this database or namespace".into(),
|
||||
};
|
||||
let after = if let Some(token) = options.page_token.filter(|token| !token.is_empty()) {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(token).map_err(|_| invalid())?;
|
||||
let (database, prefix, path, name, version): (
|
||||
String,
|
||||
Option<String>,
|
||||
Vec<String>,
|
||||
String,
|
||||
String,
|
||||
) = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
|
||||
if database != self.inner.database
|
||||
|| prefix != self.inner.database_prefix
|
||||
|| path != namespace
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
Some((name, version))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut sql_options = vec![
|
||||
format!("limit = {}", u64::from(limit) + 1),
|
||||
"include_definition = true".to_owned(),
|
||||
];
|
||||
if let Some((name, version)) = &after {
|
||||
sql_options.push(format!("after_name = {}", literal(name)));
|
||||
sql_options.push(format!("after_version = {}", literal(version)));
|
||||
}
|
||||
let mut items: Vec<FunctionVersion> = self
|
||||
.object_job(
|
||||
ExecuteQueryRequest {
|
||||
query: format!("SHOW FUNCTIONS WITH ({})", sql_options.join(", ")),
|
||||
default_namespace_path: namespace.to_vec(),
|
||||
parameters: None,
|
||||
},
|
||||
|batches| {
|
||||
strings(&batches, "definition")?
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
serde_json::from_str(&value).map_err(|error| Error::Runtime {
|
||||
message: error.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<FunctionVersion>>>()
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.wait()
|
||||
.await?;
|
||||
items.sort_by(|a, b| (a.name(), a.version()).cmp(&(b.name(), b.version())));
|
||||
if let Some((name, version)) = after {
|
||||
items.retain(|item| (item.name(), item.version()) > (name.as_str(), version.as_str()));
|
||||
}
|
||||
let page_token = if items.len() > limit as usize {
|
||||
items.truncate(limit as usize);
|
||||
let last = items.last().unwrap();
|
||||
let bytes = serde_json::to_vec(&(
|
||||
&self.inner.database,
|
||||
&self.inner.database_prefix,
|
||||
namespace,
|
||||
last.name(),
|
||||
last.version(),
|
||||
))
|
||||
.map_err(|error| Error::Runtime {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
Some(URL_SAFE_NO_PAD.encode(bytes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ListingPage { items, page_token })
|
||||
}
|
||||
pub(in crate::remote) async fn drop_function(
|
||||
&self,
|
||||
name: &str,
|
||||
version: &str,
|
||||
namespace: &[String],
|
||||
) -> Result<(bool, Job)> {
|
||||
validate_function_name(name)?;
|
||||
validate_namespace(namespace)?;
|
||||
let job = self
|
||||
.object_job(
|
||||
ExecuteQueryRequest {
|
||||
query: format!(
|
||||
"DROP FUNCTION IF EXISTS {} WITH (version = {})",
|
||||
quote_identifier(name),
|
||||
literal(version)
|
||||
),
|
||||
default_namespace_path: namespace.to_vec(),
|
||||
parameters: None,
|
||||
},
|
||||
|batches| {
|
||||
let rows = strings(&batches, "status")?;
|
||||
match rows.as_slice() {
|
||||
[status] if status == "dropped" => Ok(true),
|
||||
[status] if status == "not_found" => Ok(false),
|
||||
_ => Err(Error::Runtime {
|
||||
message: "invalid DROP FUNCTION result".into(),
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let id = job.id().unwrap().parse().map_err(|_| Error::Runtime {
|
||||
message: "invalid SQL query job id".into(),
|
||||
})?;
|
||||
let dropped = job.wait().await?;
|
||||
Ok((
|
||||
dropped,
|
||||
Job::from_query_task(id, tokio::spawn(async { Ok(()) }), || async { Ok(()) }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use super::SqlClient;
|
||||
use crate::{
|
||||
database::ExecuteQueryRequest,
|
||||
error::{Error, Result},
|
||||
job::Job,
|
||||
};
|
||||
use arrow_array::{Array, RecordBatch, StringArray};
|
||||
use futures::TryStreamExt;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
pub(super) fn strings(batches: &[RecordBatch], name: &str) -> Result<Vec<String>> {
|
||||
batches
|
||||
.iter()
|
||||
.map(|batch| {
|
||||
let values = batch
|
||||
.column_by_name(name)
|
||||
.and_then(|column| column.as_any().downcast_ref::<StringArray>())
|
||||
.ok_or_else(|| Error::Runtime {
|
||||
message: format!("SQL result is missing UTF-8 column '{name}'"),
|
||||
})?;
|
||||
if values.null_count() != 0 {
|
||||
return Err(Error::Runtime {
|
||||
message: format!("SQL result contains null '{name}'"),
|
||||
});
|
||||
}
|
||||
Ok(values
|
||||
.iter()
|
||||
.map(|value| value.unwrap().to_owned())
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(|rows| rows.into_iter().flatten().collect())
|
||||
}
|
||||
pub(super) fn definition<T: DeserializeOwned>(batches: Vec<RecordBatch>) -> Result<T> {
|
||||
let values = strings(&batches, "definition")?;
|
||||
let [value]: [String; 1] = values.try_into().map_err(|_| Error::Runtime {
|
||||
message: "SQL definition command must return exactly one row".into(),
|
||||
})?;
|
||||
serde_json::from_str(&value).map_err(|error| Error::Runtime {
|
||||
message: format!("invalid SQL definition: {error}"),
|
||||
})
|
||||
}
|
||||
impl SqlClient {
|
||||
pub(super) async fn object_job<T, F>(
|
||||
&self,
|
||||
request: ExecuteQueryRequest,
|
||||
decode: F,
|
||||
) -> Result<Job<T>>
|
||||
where
|
||||
T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
|
||||
F: FnOnce(Vec<RecordBatch>) -> Result<T> + Send + 'static,
|
||||
{
|
||||
let query = Arc::new(self.execute(request).await?);
|
||||
let id = query.id();
|
||||
let execution = query.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let reader = execution.reader().await?;
|
||||
decode(reader.try_collect().await?)
|
||||
});
|
||||
Ok(Job::from_query_task(id, task, move || {
|
||||
let query = query.clone();
|
||||
async move { query.cancel().await }
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -2270,3 +2270,167 @@ async fn storage_integration_methods_use_sql_with_namespace_and_typed_metadata()
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn function_creation_and_description_use_sql_query_handles() {
|
||||
const VERSION: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
|
||||
);
|
||||
const REQUEST: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
|
||||
);
|
||||
for defer in [false, true] {
|
||||
let mut request_json: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
|
||||
request_json["secret_bindings"] = serde_json::json!([{
|
||||
"kind": "env",
|
||||
"variable": "HF_TOKEN",
|
||||
"secret_ref": {"name": "hf-prod"},
|
||||
}]);
|
||||
let mut expected_request = request_json.clone();
|
||||
expected_request.as_object_mut().unwrap().remove("name");
|
||||
let request =
|
||||
crate::function::FunctionRegistrationRequest::from_json(&request_json.to_string())
|
||||
.unwrap();
|
||||
let result = RecordBatch::try_from_iter(vec![(
|
||||
"definition",
|
||||
Arc::new(StringArray::from(vec![VERSION])) as ArrayRef,
|
||||
)])
|
||||
.unwrap();
|
||||
let service = TestSqlService {
|
||||
adaptive: true,
|
||||
defer,
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
let statements = service.statements.clone();
|
||||
let (client, shutdown) = start_sql_service(service, ClientConfig::default()).await;
|
||||
let db = RemoteDatabase::new_mock(|request| -> http::Response<String> {
|
||||
panic!("unexpected REST request: {}", request.url().path());
|
||||
})
|
||||
.with_sql_client(client.clone());
|
||||
let job = db
|
||||
.create_function_async(request, &["team".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(job.job_type().as_deref(), Some("query"));
|
||||
let id = Uuid::parse_str(job.id().unwrap()).unwrap();
|
||||
assert_eq!(job.wait().await.unwrap().name(), "embed");
|
||||
assert_eq!(job.wait().await.unwrap().version(), "1");
|
||||
assert_eq!(
|
||||
client.describe(id).await.unwrap().status,
|
||||
QueryStatus::Finished
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_function("embed", "1", &["team".into()])
|
||||
.await
|
||||
.unwrap()
|
||||
.name(),
|
||||
"embed"
|
||||
);
|
||||
let seen = statements.lock().unwrap();
|
||||
let encoded = seen[0]
|
||||
.strip_prefix("CREATE FUNCTION \"normalize_score\" FROM '")
|
||||
.unwrap()
|
||||
.strip_suffix('\'')
|
||||
.unwrap();
|
||||
let sent: serde_json::Value = serde_json::from_str(&encoded.replace("''", "'")).unwrap();
|
||||
assert_eq!(sent, expected_request);
|
||||
assert_eq!(seen[1], "DESCRIBE FUNCTION \"embed\" WITH (version = '1')");
|
||||
shutdown.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn function_drop_keeps_the_query_id_and_reports_catalog_removal() {
|
||||
for (status, expected) in [("dropped", true), ("not_found", false)] {
|
||||
let result = RecordBatch::try_from_iter(vec![(
|
||||
"status",
|
||||
Arc::new(StringArray::from(vec![status])) as ArrayRef,
|
||||
)])
|
||||
.unwrap();
|
||||
let service = TestSqlService {
|
||||
adaptive: true,
|
||||
defer: true,
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
let (client, shutdown) = start_sql_service(service, ClientConfig::default()).await;
|
||||
let db = RemoteDatabase::new_mock(|_| -> http::Response<String> {
|
||||
panic!("unexpected REST request");
|
||||
})
|
||||
.with_sql_client(client.clone());
|
||||
let (dropped, job) = db.drop_function_async("embed", "1", &[]).await.unwrap();
|
||||
assert_eq!(dropped, expected);
|
||||
assert_eq!(job.job_type().as_deref(), Some("query"));
|
||||
let id = Uuid::parse_str(job.id().unwrap()).unwrap();
|
||||
job.wait().await.unwrap();
|
||||
job.wait().await.unwrap();
|
||||
assert_eq!(
|
||||
client.describe(id).await.unwrap().status,
|
||||
QueryStatus::Finished
|
||||
);
|
||||
shutdown.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn function_listing_uses_bounded_sql_and_scoped_version_cursors() {
|
||||
const VERSION: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
|
||||
);
|
||||
let mut second: serde_json::Value = serde_json::from_str(VERSION).unwrap();
|
||||
second["version"] = serde_json::json!("2");
|
||||
let second = second.to_string();
|
||||
let result = RecordBatch::try_from_iter(vec![(
|
||||
"definition",
|
||||
Arc::new(StringArray::from(vec![VERSION, second.as_str()])) as ArrayRef,
|
||||
)])
|
||||
.unwrap();
|
||||
let service = TestSqlService {
|
||||
adaptive: true,
|
||||
defer: true,
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
let statements = service.statements.clone();
|
||||
let (client, shutdown) = start_sql_service(service, ClientConfig::default()).await;
|
||||
let db = RemoteDatabase::new_mock(|_| -> http::Response<String> {
|
||||
panic!("unexpected REST request");
|
||||
})
|
||||
.with_sql_client(client);
|
||||
let first = db
|
||||
.list_functions(
|
||||
&["team".into()],
|
||||
crate::listing::ListingOptions::default().page_limit(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.items.len(), 1);
|
||||
let token = first.page_token.unwrap();
|
||||
let second = db
|
||||
.list_functions(
|
||||
&["team".into()],
|
||||
crate::listing::ListingOptions::default()
|
||||
.page_limit(1)
|
||||
.page_token(token.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.items.len(), 1);
|
||||
assert_eq!(second.items[0].version(), "2");
|
||||
assert!(second.page_token.is_none());
|
||||
assert!(
|
||||
db.list_functions(
|
||||
&["other".into()],
|
||||
crate::listing::ListingOptions::default()
|
||||
.page_limit(1)
|
||||
.page_token(token)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let seen = statements.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen[0].contains("limit = 2"));
|
||||
assert!(seen[1].contains("after_version = '1'"));
|
||||
shutdown.send(()).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user