From ac19aefe3cecee6a53c4e22369f97fce0f1d675b Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 15 Sep 2026 02:52:26 +0800 Subject: [PATCH] feat: represent registered Functions by OCI image identity --- docs/src/python/python.md | 4 + python/python/lancedb/functions.py | 20 ++--- .../tests/test_first_class_function_slice1.py | 15 ++-- .../tests/test_first_class_function_slice2.py | 41 +++++----- rust/lancedb/src/function.rs | 46 ++++-------- .../tests/first_class_function_slice1.rs | 27 +++---- ...remote_fixed_size_declaration_request.json | 2 +- .../v1/remote_function_job.json | 74 ++++++++++++++----- .../v1/remote_function_version.canonical.json | 2 +- .../v1/remote_scalar_declaration_request.json | 2 +- 10 files changed, 130 insertions(+), 103 deletions(-) diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 28e774473..072160ef9 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -133,10 +133,14 @@ listing a storage directory. ::: lancedb.functions.PythonAdapterSpec +::: lancedb.functions.FunctionImage + ::: lancedb.functions.FunctionVersion ::: lancedb.functions.PythonRuntimeSpec +::: lancedb.functions.FunctionImage + ::: lancedb.functions.FunctionVersionRef ::: lancedb.functions.ApplicationInput diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index d3fa6da81..ca5b396f2 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -295,20 +295,21 @@ class PythonRuntimeSpec(_RemoteValue): return self -class FunctionVersion(_RemoteValue): - """An exact immutable Function version returned by Enterprise. +class FunctionImage(_RemoteValue): + """A complete OCI Function image identified by its exact manifest digest.""" - The GPU execution requirement is part of this identity. CPU and memory sizing, - priority, concurrency, and retry policy belong to the execution platform. - """ + manifest_digest: str + descriptor: Mapping[str, Any] + source: bool + + +class FunctionVersion(_RemoteValue): + """An immutable catalog name bound to a concrete OCI Function image.""" name: str version: str - artifact: FunctionArtifact + image: FunctionImage signature: FunctionSignature - runtime: PythonRuntimeSpec - runtime_digest: str - environment_digest: str created_at: str def __call__(self, **inputs: Any) -> FunctionApplication: @@ -1402,6 +1403,7 @@ __all__ = [ "FunctionRegistrationRequest", "FunctionResultField", "FunctionSignature", + "FunctionImage", "FunctionVersion", "FunctionVersionRef", "InputBinding", diff --git a/python/python/tests/test_first_class_function_slice1.py b/python/python/tests/test_first_class_function_slice1.py index 89172ba3f..124f9a6ef 100644 --- a/python/python/tests/test_first_class_function_slice1.py +++ b/python/python/tests/test_first_class_function_slice1.py @@ -93,12 +93,13 @@ def test_function_version_identity_is_immutable_and_exact(): value = job_result("remote_function_job.json") version = FunctionVersion.from_json(json.dumps(value)) assert version.name == "embed" - assert version.version == "fv_01K3EXACT" + assert version.version == version.image.manifest_digest + assert version.version.startswith("sha256:") with pytest.raises((TypeError, ValueError)): version.version = "fv_changed" with pytest.raises(TypeError, match="immutable"): - version.runtime.env["TOKENIZERS_PARALLELISM"] = "true" + version.image.descriptor["format_version"] = "changed" changed = dict(value) changed["version"] = "fv_changed" @@ -182,14 +183,14 @@ def test_function_version_keeps_named_struct_outputs_in_one_application(): def test_unknown_fields_and_discriminators_are_forward_decodable(): value = job_result("remote_function_job.json") value["future_version_metadata"] = {"retention_class": "catalog"} - value["runtime"] = {"kind": "wasm", "module_digest": "sha256:wasm"} + value["image"]["descriptor"]["future_interface"] = {"kind": "wasm"} value["signature"]["output"]["kind"] = "future_output_shape" version = FunctionVersion.from_json(json.dumps(value)) - assert version.runtime.kind == "wasm" - assert version.runtime.python_version is None - assert version.runtime.environment is None - assert json.loads(version.to_canonical_json())["runtime"] == {"kind": "wasm"} + assert version.image.descriptor["future_interface"] == {"kind": "wasm"} + assert json.loads(version.to_canonical_json())["image"]["descriptor"][ + "future_interface" + ] == {"kind": "wasm"} assert version.signature.output.kind == "future_output_shape" diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 900f69f8d..16038b67a 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -45,6 +45,11 @@ FIXTURES = ( ) +IMAGE_VERSION = json.loads( + (FIXTURES / "remote_function_version.canonical.json").read_text() +)["version"] + + @udf( pip=["numpy>=2"], env={"MODE": "test"}, @@ -1199,11 +1204,11 @@ def test_local_function_catalog_operations_are_not_supported(tmp_path): with pytest.raises(NotImplementedError, match=message): db.create_function_async(normalize_score) with pytest.raises(NotImplementedError, match=message): - db.get_function("normalize_score", version="fv_exact") + db.get_function("normalize_score", version=IMAGE_VERSION) with pytest.raises(NotImplementedError, match=message): db.list_functions() with pytest.raises(NotImplementedError, match=message): - db.drop_function("normalize_score", version="fv_exact") + db.drop_function("normalize_score", version=IMAGE_VERSION) @contextlib.contextmanager @@ -1230,15 +1235,13 @@ def _mock_remote_function_catalog(): if self.path == "/v1/function/normalize_score/create": state["version"] = { "name": "normalize_score", - "version": "fv_exact", - "artifact": { - key: body["artifact"][key] - for key in ("kind", "digest", "entrypoint") - }, + "version": IMAGE_VERSION, + "image": json.loads( + ( + FIXTURES / "remote_function_version.canonical.json" + ).read_text() + )["image"], "signature": body["signature"], - "runtime": body["runtime"], - "runtime_digest": "sha256:runtime", - "environment_digest": "sha256:environment", "created_at": "2026-08-21T00:00:00Z", } response = {"job_id": "job-register"} @@ -1252,10 +1255,10 @@ def _mock_remote_function_catalog(): "result": state["version"], } elif self.path == "/v1/function/normalize_score/describe": - assert body == {"version": "fv_exact"} + assert body == {"version": IMAGE_VERSION} response = state["version"] elif self.path == "/v1/function/normalize_score/drop": - assert body == {"version": "fv_exact"} + assert body == {"version": IMAGE_VERSION} response = {"dropped": True} else: status = 404 @@ -1278,7 +1281,7 @@ def _mock_remote_function_catalog(): "functions": [ { "name": "normalize_score", - "version": "fv_exact", + "version": IMAGE_VERSION, "definition": state["version"], } ], @@ -1314,7 +1317,7 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip(): assert created == reopened assert reopened.name == "normalize_score" - assert reopened.version == "fv_exact" + assert reopened.version == IMAGE_VERSION create_request = state["requests"][0][1] expected_request = json.loads( normalize_score.registration_request.to_canonical_json() @@ -1334,7 +1337,7 @@ def test_blocking_remote_registration_returns_function_version(): created = db.create_function(normalize_score) assert created.name == "normalize_score" - assert created.version == "fv_exact" + assert created.version == IMAGE_VERSION assert [path for path, _ in state["requests"]] == [ "/v1/function/normalize_score/create", "/v1/jobs/describe", @@ -1392,12 +1395,12 @@ def test_remote_drop_function_sends_exact_version(): host_override=host, client_config={"retry_config": {"retries": 0}}, ) - assert db.drop_function("normalize_score", version="fv_exact") is True + assert db.drop_function("normalize_score", version=IMAGE_VERSION) is True assert state["requests"] == [ ( "/v1/function/normalize_score/drop", - {"version": "fv_exact"}, + {"version": IMAGE_VERSION}, ) ] @@ -1411,11 +1414,11 @@ async def test_async_remote_drop_function_sends_exact_version(): host_override=host, client_config={"retry_config": {"retries": 0}}, ) - assert await db.drop_function("normalize_score", version="fv_exact") is True + assert await db.drop_function("normalize_score", version=IMAGE_VERSION) is True assert state["requests"] == [ ( "/v1/function/normalize_score/drop", - {"version": "fv_exact"}, + {"version": IMAGE_VERSION}, ) ] diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index e67d763ac..843b7fc97 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -396,19 +396,21 @@ impl Serialize for PythonRuntimeSpec { } } -/// Immutable Function version returned by the Enterprise catalog. -/// -/// The GPU execution requirement is part of this identity. CPU and memory sizing, -/// priority, concurrency, and retry policy belong to the execution platform. +/// A complete OCI Function image. Its digest is independent of catalog names. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionImage { + pub manifest_digest: String, + pub descriptor: Value, + pub source: bool, +} + +/// An immutable catalog name bound to a concrete OCI manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionVersion { name: String, version: String, - artifact: FunctionArtifact, + image: FunctionImage, signature: FunctionSignature, - runtime: PythonRuntimeSpec, - runtime_digest: String, - environment_digest: String, created_at: String, } @@ -416,31 +418,15 @@ impl FunctionVersion { pub fn name(&self) -> &str { &self.name } - pub fn version(&self) -> &str { &self.version } - - pub fn artifact(&self) -> &FunctionArtifact { - &self.artifact + pub fn image(&self) -> &FunctionImage { + &self.image } - pub fn signature(&self) -> &FunctionSignature { &self.signature } - - pub fn runtime(&self) -> &PythonRuntimeSpec { - &self.runtime - } - - pub fn runtime_digest(&self) -> &str { - &self.runtime_digest - } - - pub fn environment_digest(&self) -> &str { - &self.environment_digest - } - pub fn created_at(&self) -> &str { &self.created_at } @@ -697,11 +683,9 @@ mod conda_environment_tests { ) .unwrap(); assert_eq!(conda.channels, ["conda-forge"]); - assert!( - serde_json::to_string(&conda) - .unwrap() - .contains(r#""channels":["conda-forge"]"#) - ); + assert!(serde_json::to_string(&conda) + .unwrap() + .contains(r#""channels":["conda-forge"]"#)); let pip: PythonEnvironmentSpec = serde_json::from_str(r#"{"kind":"pip","packages":["numpy"]}"#).unwrap(); diff --git a/rust/lancedb/tests/first_class_function_slice1.rs b/rust/lancedb/tests/first_class_function_slice1.rs index ce020bd53..ded5ac7c3 100644 --- a/rust/lancedb/tests/first_class_function_slice1.rs +++ b/rust/lancedb/tests/first_class_function_slice1.rs @@ -26,8 +26,11 @@ fn function_version_job_result_matches_shared_canonical_golden() { let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); assert_eq!(version.name(), "embed"); - assert_eq!(version.version(), "fv_01K3EXACT"); - assert_eq!(version.runtime_digest(), "sha256:runtime"); + assert_eq!( + version.version(), + "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6" + ); + assert_eq!(version.image().manifest_digest, version.version()); assert_eq!( version.to_canonical_json().expect("canonical JSON"), fixture("remote_function_version.canonical.json").trim() @@ -113,22 +116,18 @@ fn refresh_job_result_matches_shared_canonical_golden() { fn unknown_fields_and_discriminators_are_forward_decodable() { let mut result = job_result("remote_function_job.json"); result["future_version_metadata"] = serde_json::json!({"retention_class": "catalog"}); - result["runtime"] = serde_json::json!({ - "kind": "wasm", - "module_digest": "sha256:wasm" - }); + result["image"]["descriptor"]["future_interface"] = serde_json::json!({"version": 2}); result["signature"]["output"]["kind"] = Value::String("future_output_shape".to_string()); let version = FunctionVersion::from_json(&result.to_string()).expect("future remote value"); - assert_eq!(version.runtime().kind(), "wasm"); - assert_eq!(version.runtime().python_version(), None); + assert_eq!(version.image().descriptor["future_interface"]["version"], 2); assert_eq!(version.signature().output.kind, "future_output_shape"); assert_eq!( serde_json::from_str::( &version.to_canonical_json().expect("canonical future value") ) - .expect("canonical JSON")["runtime"], - serde_json::json!({"kind": "wasm"}) + .expect("canonical JSON")["image"]["descriptor"]["future_interface"], + serde_json::json!({"version": 2}) ); } @@ -136,9 +135,7 @@ fn unknown_fields_and_discriminators_are_forward_decodable() { fn floating_point_application_literals_are_rejected_consistently() { let error = FunctionApplication::from_json(&fixture("remote_function_application_float.json")) .unwrap_err(); - assert!( - error - .to_string() - .contains("floating-point Function literals") - ); + assert!(error + .to_string() + .contains("floating-point Function literals")); } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json index fd3944412..19a5452a6 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json @@ -9,7 +9,7 @@ "application": { "function": { "name": "embed", - "version": "fv_01K3EXACT" + "version": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6" }, "inputs": [ { diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json index 39a279692..49e7ef61b 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json @@ -3,28 +3,64 @@ "job_type": "create_function", "job_state": "DONE", "creation_ms": 1787270400000, - "spec": {"name": "embed"}, + "spec": { + "name": "embed" + }, "result": { "name": "embed", - "version": "fv_01K3EXACT", - "artifact": { - "kind": "python_callable", - "digest": "sha256:code", - "entrypoint": "embed" - }, + "version": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6", "signature": { - "inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}], - "output": {"kind": "scalar", "arrow_type": "list", "nullable": false} + "inputs": [ + { + "name": "text", + "arrow_type": "utf8", + "nullable": true + } + ], + "output": { + "kind": "scalar", + "arrow_type": "list", + "nullable": false + } }, - "runtime": { - "kind": "python", - "python_version": "3.12", - "environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]}, - "env": {"TOKENIZERS_PARALLELISM": "false"} - }, - "runtime_digest": "sha256:runtime", - "environment_digest": "sha256:environment", - "created_at": "2026-08-21T00:00:00Z" + "created_at": "2026-08-21T00:00:00Z", + "image": { + "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6", + "descriptor": { + "format_version": 1, + "python": { + "implementation": "cpython", + "version": "3.12.14", + "abi_tag": "cp312", + "executable": "/usr/local/bin/python3", + "import_paths": [ + "/opt/function/code" + ] + }, + "python_api": 1, + "entrypoint": "app.function:create", + "interface": { + "type": "lance.scalar", + "version": 1 + }, + "schemas": { + "input": "/opt/function/schemas/input.arrow", + "output": "/opt/function/schemas/output.arrow", + "initialization": "/opt/function/schemas/initialization.arrow" + }, + "requires": { + "kernel_min": "4.18.0", + "capabilities": [] + }, + "behavior": { + "result_stability": "input_and_context", + "side_effects": "non_idempotent" + } + }, + "source": false + } }, - "future_job": {"trace_id": "trace-1"} + "future_job": { + "trace_id": "trace-1" + } } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json index 2670ad0b2..a7a2be434 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json @@ -1 +1 @@ -{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"} +{"created_at":"2026-08-21T00:00:00Z","image":{"descriptor":{"behavior":{"result_stability":"input_and_context","side_effects":"non_idempotent"},"entrypoint":"app.function:create","format_version":1,"interface":{"type":"lance.scalar","version":1},"python":{"abi_tag":"cp312","executable":"/usr/local/bin/python3","implementation":"cpython","import_paths":["/opt/function/code"],"version":"3.12.14"},"python_api":1,"requires":{"capabilities":[],"kernel_min":"4.18.0"},"schemas":{"initialization":"/opt/function/schemas/initialization.arrow","input":"/opt/function/schemas/input.arrow","output":"/opt/function/schemas/output.arrow"}},"manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","source":false},"name":"embed","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json index 357834de0..a983a3c3d 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json @@ -4,7 +4,7 @@ ], "function": { "application": { - "function": {"name": "embed", "version": "fv_01K3EXACT"}, + "function": {"name": "embed", "version": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "text", "kind": "column", "value": {"path": "description"}} ],