From 12c621451c48ba8944fb8c9e5c46663143259eac Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 13:00:28 -0500 Subject: [PATCH 01/14] Adopt structured error codes for plugin UDF control signals Per ADR 0001 in the Rust wasm-udf-server, control-signal error responses are now JSON of the form `{"message": "...", "code": "..."}`, and consumers read `"message"` instead of `"error"` for human-readable text. The Python plugin server in `singlestoredb/functions/ext/plugin/` mirrors this wire protocol and was returning plain text on errors, which would break consumers updated to the new ADR shape. Errors from `@@register`, `@@delete`, and unknown `@@`-prefixed signals now carry a stable code (REGISTER_MISSING_PAYLOAD, REGISTER_INVALID_PAYLOAD, REGISTER_FUNC_EXISTS, DELETE_MISSING_PAYLOAD, DELETE_INVALID_PAYLOAD, DELETE_FUNC_NOT_FOUND, DELETE_FUNC_NOT_REGISTERED, UNKNOWN_SIGNAL). The REGISTER_DISABLED / DELETE_DISABLED codes from the catalog have no call site here because this server has no enable-register flag. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 78 +++++++++++++------ singlestoredb/tests/test_plugin.py | 66 ++++++++++++---- 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 302cc8aaf..a17b1580e 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -1,7 +1,11 @@ """ Control signal dispatch for @@health, @@functions, @@register, @@delete. -Matches the Rust wasm-udf-server's dispatch_control_signal behavior. +Matches the Rust wasm-udf-server's dispatch_control_signal behavior, including +the structured-error-code shape from ADR 0001 +(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). The +``REGISTER_DISABLED`` and ``DELETE_DISABLED`` codes from that catalog have no +call site here because this server has no registration enable/disable flag. """ from __future__ import annotations @@ -22,7 +26,32 @@ class ControlResult: """Result of a control signal dispatch.""" ok: bool - data: str # JSON response on success, error message on failure + data: str # JSON response on success or failure (per ADR 0001) + + +def _err(message: str, code: str) -> ControlResult: + """Build an error ControlResult with the ADR 0001 JSON shape.""" + return ControlResult( + ok=False, + data=json.dumps({'message': message, 'code': code}), + ) + + +def _register_code_for(message: str) -> str: + """Pick a code for an exception raised by ``SharedRegistry.create_function``.""" + if 'already exists' in message \ + or 'not a dynamically registered function' in message: + return 'REGISTER_FUNC_EXISTS' + return 'REGISTER_INVALID_PAYLOAD' + + +def _delete_code_for(message: str) -> str: + """Pick a code for an exception raised by ``SharedRegistry.delete_function``.""" + if 'not a dynamically registered function' in message: + return 'DELETE_FUNC_NOT_REGISTERED' + if 'not found' in message: + return 'DELETE_FUNC_NOT_FOUND' + return 'DELETE_INVALID_PAYLOAD' def dispatch_control_signal( @@ -46,12 +75,12 @@ def dispatch_control_signal( request_data, shared_registry, pipe_write_fd, ) else: - return ControlResult( - ok=False, - data=f'Unknown control signal: {signal_name}', + return _err( + f'Unknown control signal: {signal_name}', + 'UNKNOWN_SIGNAL', ) except Exception as e: - return ControlResult(ok=False, data=str(e)) + return _err(str(e), 'UNKNOWN_SIGNAL') def _handle_health() -> ControlResult: @@ -83,36 +112,37 @@ def _handle_register( own registry and re-fork all workers. """ if not request_data: - return ControlResult(ok=False, data='Missing registration payload') + return _err('Missing registration payload', 'REGISTER_MISSING_PAYLOAD') try: body = json.loads(request_data) except json.JSONDecodeError as e: - return ControlResult(ok=False, data=f'Invalid JSON: {e}') + return _err(f'Invalid JSON: {e}', 'REGISTER_INVALID_PAYLOAD') function_name = body.get('name') if not function_name: - return ControlResult( - ok=False, data='Missing required field: name', + return _err( + 'Missing required field: name', 'REGISTER_INVALID_PAYLOAD', ) args = body.get('args') if not isinstance(args, list): - return ControlResult( - ok=False, data='Missing required field: args (must be an array)', + return _err( + 'Missing required field: args (must be an array)', + 'REGISTER_INVALID_PAYLOAD', ) returns = body.get('returns') if not isinstance(returns, list): - return ControlResult( - ok=False, - data='Missing required field: returns (must be an array)', + return _err( + 'Missing required field: returns (must be an array)', + 'REGISTER_INVALID_PAYLOAD', ) func_body = body.get('body') if not func_body: - return ControlResult( - ok=False, data='Missing required field: body', + return _err( + 'Missing required field: body', 'REGISTER_INVALID_PAYLOAD', ) replace = body.get('replace', False) @@ -127,7 +157,8 @@ def _handle_register( try: shared_registry.create_function(signature, func_body, replace) except Exception as e: - return ControlResult(ok=False, data=str(e)) + msg = str(e) + return _err(msg, _register_code_for(msg)) # Notify main process so it can re-fork workers with updated state if pipe_write_fd is not None: @@ -151,23 +182,24 @@ def _handle_delete( ) -> ControlResult: """Handle @@delete: delete a dynamically registered function.""" if not request_data: - return ControlResult(ok=False, data='Missing deletion payload') + return _err('Missing deletion payload', 'DELETE_MISSING_PAYLOAD') try: body = json.loads(request_data) except json.JSONDecodeError as e: - return ControlResult(ok=False, data=f'Invalid JSON: {e}') + return _err(f'Invalid JSON: {e}', 'DELETE_INVALID_PAYLOAD') function_name = body.get('name') if not function_name: - return ControlResult( - ok=False, data='Missing required field: name', + return _err( + 'Missing required field: name', 'DELETE_INVALID_PAYLOAD', ) try: shared_registry.delete_function(function_name) except ValueError as e: - return ControlResult(ok=False, data=str(e)) + msg = str(e) + return _err(msg, _delete_code_for(msg)) # Notify main process so it can re-fork workers with updated state if pipe_write_fd is not None: diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index 4e105d9e8..d8094e792 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -133,26 +133,34 @@ def test_unknown_signal(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@unknown', b'', shared) assert result.ok is False - assert 'Unknown control signal' in result.data + body = json.loads(result.data) + assert body['code'] == 'UNKNOWN_SIGNAL' + assert 'Unknown control signal' in body['message'] def test_register_missing_payload(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@register', b'', shared) assert result.ok is False - assert 'Missing registration payload' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_MISSING_PAYLOAD' + assert 'Missing registration payload' in body['message'] def test_register_invalid_json(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@register', b'not json', shared) assert result.ok is False - assert 'Invalid JSON' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'Invalid JSON' in body['message'] def test_register_missing_function_name(self): shared = self._make_shared_registry() payload = json.dumps({'args': [], 'returns': [], 'body': 'x'}).encode() result = dispatch_control_signal('@@register', payload, shared) assert result.ok is False - assert 'name' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'name' in body['message'] def test_register_missing_args(self): shared = self._make_shared_registry() @@ -161,7 +169,9 @@ def test_register_missing_args(self): }).encode() result = dispatch_control_signal('@@register', payload, shared) assert result.ok is False - assert 'args' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'args' in body['message'] def test_register_missing_returns(self): shared = self._make_shared_registry() @@ -170,7 +180,9 @@ def test_register_missing_returns(self): }).encode() result = dispatch_control_signal('@@register', payload, shared) assert result.ok is False - assert 'returns' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'returns' in body['message'] def test_register_missing_body(self): shared = self._make_shared_registry() @@ -179,7 +191,9 @@ def test_register_missing_body(self): }).encode() result = dispatch_control_signal('@@register', payload, shared) assert result.ok is False - assert 'body' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'body' in body['message'] def test_register_args_not_list(self): shared = self._make_shared_registry() @@ -189,26 +203,48 @@ def test_register_args_not_list(self): }).encode() result = dispatch_control_signal('@@register', payload, shared) assert result.ok is False - assert 'args' in result.data + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'args' in body['message'] + + def test_register_func_exists(self): + shared = self._make_shared_registry() + shared.create_function.side_effect = ValueError( + 'Function "f" already exists (use replace=true to overwrite)', + ) + payload = json.dumps({ + 'name': 'f', 'args': [], 'returns': [], 'body': 'x', + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_FUNC_EXISTS' + assert 'already exists' in body['message'] def test_delete_missing_payload(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@delete', b'', shared) assert result.ok is False - assert 'Missing deletion payload' in result.data + body = json.loads(result.data) + assert body['code'] == 'DELETE_MISSING_PAYLOAD' + assert 'Missing deletion payload' in body['message'] def test_delete_invalid_json(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@delete', b'not json', shared) assert result.ok is False - assert 'Invalid JSON' in result.data + body = json.loads(result.data) + assert body['code'] == 'DELETE_INVALID_PAYLOAD' + assert 'Invalid JSON' in body['message'] def test_delete_missing_function_name(self): shared = self._make_shared_registry() payload = json.dumps({}).encode() result = dispatch_control_signal('@@delete', payload, shared) assert result.ok is False - assert 'name' in result.data + body = json.loads(result.data) + assert body['code'] == 'DELETE_INVALID_PAYLOAD' + assert 'name' in body['message'] def test_delete_nonexistent_function(self): shared = self._make_shared_registry() @@ -218,7 +254,9 @@ def test_delete_nonexistent_function(self): payload = json.dumps({'name': 'no_such'}).encode() result = dispatch_control_signal('@@delete', payload, shared) assert result.ok is False - assert 'not found' in result.data + body = json.loads(result.data) + assert body['code'] == 'DELETE_FUNC_NOT_FOUND' + assert 'not found' in body['message'] def test_delete_base_function(self): shared = self._make_shared_registry() @@ -228,7 +266,9 @@ def test_delete_base_function(self): payload = json.dumps({'name': 'base_fn'}).encode() result = dispatch_control_signal('@@delete', payload, shared) assert result.ok is False - assert 'not a dynamically registered function' in result.data + body = json.loads(result.data) + assert body['code'] == 'DELETE_FUNC_NOT_REGISTERED' + assert 'not a dynamically registered function' in body['message'] def test_delete_success(self): shared = self._make_shared_registry() From 96479d5b5ab1a96752be4422db68e418dc2b1c8c Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 13:19:42 -0500 Subject: [PATCH 02/14] Distinguish replace-built-in from already-exists in @@register error code `_register_code_for` was folding "Cannot replace '...': not a dynamically registered function" into REGISTER_FUNC_EXISTS via a too-loose substring match. That misled clients: replace=true cannot fix the built-in case, so a duplicate-registration prompt is wrong advice. Route that path through a new REGISTER_FUNC_NOT_DYNAMIC code (mirrors DELETE_FUNC_NOT_REGISTERED on the register side), and require both "Cannot replace" and "not a dynamically registered function" to match so the genuine `already exists` case still maps to REGISTER_FUNC_EXISTS. ADR 0001 in wasm-udf-server has been updated locally with the new code; the Rust matcher in server.rs:457-462 has the same gap and will be fixed in a follow-up there. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 6 ++++-- singlestoredb/tests/test_plugin.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index a17b1580e..bd714a2ed 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -39,8 +39,10 @@ def _err(message: str, code: str) -> ControlResult: def _register_code_for(message: str) -> str: """Pick a code for an exception raised by ``SharedRegistry.create_function``.""" - if 'already exists' in message \ - or 'not a dynamically registered function' in message: + if 'Cannot replace' in message \ + and 'not a dynamically registered function' in message: + return 'REGISTER_FUNC_NOT_DYNAMIC' + if 'already exists' in message: return 'REGISTER_FUNC_EXISTS' return 'REGISTER_INVALID_PAYLOAD' diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index d8094e792..058527bd4 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -221,6 +221,21 @@ def test_register_func_exists(self): assert body['code'] == 'REGISTER_FUNC_EXISTS' assert 'already exists' in body['message'] + def test_register_replace_base_function(self): + shared = self._make_shared_registry() + shared.create_function.side_effect = ValueError( + "Cannot replace 'base_fn': not a dynamically registered function", + ) + payload = json.dumps({ + 'name': 'base_fn', 'args': [], 'returns': [], + 'body': 'x', 'replace': True, + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_FUNC_NOT_DYNAMIC' + assert 'not a dynamically registered function' in body['message'] + def test_delete_missing_payload(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@delete', b'', shared) From df374356b94a663ca047afadbbedd60ef949a1c9 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 13:33:59 -0500 Subject: [PATCH 03/14] Use INTERNAL_ERROR for unexpected handler exceptions The outer except in dispatch_control_signal blanket-mapped any handler exception to UNKNOWN_SIGNAL, which the catalog reserves for unrecognized @@-prefixed signal names. Internal failures (e.g. registry blow-ups in @@functions, post-success pipe write errors in @@register/@@delete) were mislabeled as bad signals. Return INTERNAL_ERROR for that path; the unrecognized-name branch keeps UNKNOWN_SIGNAL. --- singlestoredb/functions/ext/plugin/control.py | 6 ++++-- singlestoredb/tests/test_plugin.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index bd714a2ed..498be94c1 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -3,7 +3,9 @@ Matches the Rust wasm-udf-server's dispatch_control_signal behavior, including the structured-error-code shape from ADR 0001 -(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). The +(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). ``UNKNOWN_SIGNAL`` +is reserved for unrecognized ``@@``-prefixed signal names; ``INTERNAL_ERROR`` +is the cross-cutting fallback for unexpected handler exceptions. The ``REGISTER_DISABLED`` and ``DELETE_DISABLED`` codes from that catalog have no call site here because this server has no registration enable/disable flag. """ @@ -82,7 +84,7 @@ def dispatch_control_signal( 'UNKNOWN_SIGNAL', ) except Exception as e: - return _err(str(e), 'UNKNOWN_SIGNAL') + return _err(str(e), 'INTERNAL_ERROR') def _handle_health() -> ControlResult: diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index 058527bd4..26ebeb075 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -137,6 +137,19 @@ def test_unknown_signal(self): assert body['code'] == 'UNKNOWN_SIGNAL' assert 'Unknown control signal' in body['message'] + def test_internal_error_on_handler_failure(self): + shared = self._make_shared_registry() + with patch( + 'singlestoredb.functions.ext.plugin.control' + '.describe_functions_json', + side_effect=RuntimeError('boom'), + ): + result = dispatch_control_signal('@@functions', b'', shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'INTERNAL_ERROR' + assert 'boom' in body['message'] + def test_register_missing_payload(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@register', b'', shared) From 890acef1afb75a4c58347c494cf65ef8ada1c08d Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 13:45:38 -0500 Subject: [PATCH 04/14] Document plugin control error codes and clarify ControlResult.data Expand the module docstring in `control.py` to enumerate every ADR 0001 error code emitted from this module (including the new `REGISTER_FUNC_NOT_DYNAMIC`) and point readers to ADR 0001 in wasm-udf-server as the authoritative catalog. Clarify the `ControlResult.data` field comment to distinguish handler-specific success documents from the ADR 0001 error shape. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 498be94c1..0d3bac54b 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -3,11 +3,28 @@ Matches the Rust wasm-udf-server's dispatch_control_signal behavior, including the structured-error-code shape from ADR 0001 -(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). ``UNKNOWN_SIGNAL`` -is reserved for unrecognized ``@@``-prefixed signal names; ``INTERNAL_ERROR`` -is the cross-cutting fallback for unexpected handler exceptions. The -``REGISTER_DISABLED`` and ``DELETE_DISABLED`` codes from that catalog have no -call site here because this server has no registration enable/disable flag. +(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). The authoritative +catalog of codes lives in ADR 0001 in the ``wasm-udf-server`` repo; the codes +emitted from this module are: + +- ``UNKNOWN_SIGNAL`` — unrecognized ``@@``-prefixed signal name. +- ``INTERNAL_ERROR`` — cross-cutting fallback for unexpected handler exceptions. +- ``REGISTER_MISSING_PAYLOAD`` — ``@@register`` called with an empty body. +- ``REGISTER_INVALID_PAYLOAD`` — ``@@register`` body failed JSON parsing or + field validation. +- ``REGISTER_FUNC_EXISTS`` — function with the same name is already registered + and ``replace`` was not requested. +- ``REGISTER_FUNC_NOT_DYNAMIC`` — ``replace`` requested for a function that was + not dynamically registered (e.g., a built-in). +- ``DELETE_MISSING_PAYLOAD`` — ``@@delete`` called with an empty body. +- ``DELETE_INVALID_PAYLOAD`` — ``@@delete`` body failed JSON parsing or field + validation. +- ``DELETE_FUNC_NOT_REGISTERED`` — target function exists but was not + dynamically registered, so it cannot be deleted. +- ``DELETE_FUNC_NOT_FOUND`` — target function does not exist. + +The ``REGISTER_DISABLED`` and ``DELETE_DISABLED`` codes from that catalog have +no call site here because this server has no registration enable/disable flag. """ from __future__ import annotations @@ -28,7 +45,11 @@ class ControlResult: """Result of a control signal dispatch.""" ok: bool - data: str # JSON response on success or failure (per ADR 0001) + # JSON response. On success (``ok=True``) this is a handler-specific + # document such as ``{"status":"ok"}`` or ``{"functions":[...]}``. On + # failure (``ok=False``) this is the ADR 0001 error shape + # ``{"message":"...","code":"..."}``. + data: str def _err(message: str, code: str) -> ControlResult: From 9abdf4d0b237cce1fa9a0512b18ccfe1660e227c Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 13:56:10 -0500 Subject: [PATCH 05/14] Classify register/delete control errors by exception type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace substring-matching of exception messages in _register_code_for/_delete_code_for with typed exceptions raised at the site that knows the semantic intent. Substring matching was fragile because user-supplied function names and dtypes are interpolated into the exception text — a name containing "already exists", "not found", or "not a dynamically registered function" could be misclassified as REGISTER_FUNC_EXISTS, DELETE_FUNC_NOT_FOUND, or *_NOT_DYNAMIC instead of the correct INVALID_PAYLOAD code. FunctionExistsError, FunctionNotDynamicError, and FunctionNotFoundError subclass ValueError to keep existing except ValueError sites compatible. control.py now classifies by exception type, so user input embedded in messages can no longer sway the error code. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 36 +++++++------------ .../functions/ext/plugin/registry.py | 20 ++++++++--- singlestoredb/tests/test_plugin.py | 11 +++--- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 0d3bac54b..c289ec994 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -34,6 +34,9 @@ from typing import TYPE_CHECKING from .registry import describe_functions_json +from .registry import FunctionExistsError +from .registry import FunctionNotDynamicError +from .registry import FunctionNotFoundError if TYPE_CHECKING: from .server import SharedRegistry @@ -60,25 +63,6 @@ def _err(message: str, code: str) -> ControlResult: ) -def _register_code_for(message: str) -> str: - """Pick a code for an exception raised by ``SharedRegistry.create_function``.""" - if 'Cannot replace' in message \ - and 'not a dynamically registered function' in message: - return 'REGISTER_FUNC_NOT_DYNAMIC' - if 'already exists' in message: - return 'REGISTER_FUNC_EXISTS' - return 'REGISTER_INVALID_PAYLOAD' - - -def _delete_code_for(message: str) -> str: - """Pick a code for an exception raised by ``SharedRegistry.delete_function``.""" - if 'not a dynamically registered function' in message: - return 'DELETE_FUNC_NOT_REGISTERED' - if 'not found' in message: - return 'DELETE_FUNC_NOT_FOUND' - return 'DELETE_INVALID_PAYLOAD' - - def dispatch_control_signal( signal_name: str, request_data: bytes, @@ -181,9 +165,12 @@ def _handle_register( try: shared_registry.create_function(signature, func_body, replace) + except FunctionExistsError as e: + return _err(str(e), 'REGISTER_FUNC_EXISTS') + except FunctionNotDynamicError as e: + return _err(str(e), 'REGISTER_FUNC_NOT_DYNAMIC') except Exception as e: - msg = str(e) - return _err(msg, _register_code_for(msg)) + return _err(str(e), 'REGISTER_INVALID_PAYLOAD') # Notify main process so it can re-fork workers with updated state if pipe_write_fd is not None: @@ -222,9 +209,12 @@ def _handle_delete( try: shared_registry.delete_function(function_name) + except FunctionNotDynamicError as e: + return _err(str(e), 'DELETE_FUNC_NOT_REGISTERED') + except FunctionNotFoundError as e: + return _err(str(e), 'DELETE_FUNC_NOT_FOUND') except ValueError as e: - msg = str(e) - return _err(msg, _delete_code_for(msg)) + return _err(str(e), 'DELETE_INVALID_PAYLOAD') # Notify main process so it can re-fork workers with updated state if pipe_write_fd is not None: diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index fa5096803..2057d2c6b 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -141,6 +141,18 @@ def setup_logging(level: int = logging.INFO) -> None: logger = logging.getLogger('udf_handler') +class FunctionExistsError(ValueError): + """Raised when a function name is already registered and replace=False.""" + + +class FunctionNotDynamicError(ValueError): + """Raised when replacing/deleting a function that was not dynamically registered.""" + + +class FunctionNotFoundError(ValueError): + """Raised when the target function does not exist (delete only).""" + + class FunctionRegistry: """Registry of discovered UDF functions.""" @@ -387,13 +399,13 @@ def create_function( ) if not replace and func_name in self.functions: - raise ValueError( + raise FunctionExistsError( f'Function "{func_name}" already exists ' f'(use replace=true to overwrite)', ) if func_name in self._base_function_names: - raise ValueError( + raise FunctionNotDynamicError( f"Cannot replace '{func_name}': " f'not a dynamically registered function', ) @@ -447,9 +459,9 @@ def delete_function(self, signature_json: str) -> None: 'signature JSON must contain a "name" field', ) if name not in self.functions: - raise ValueError(f"Function '{name}' not found") + raise FunctionNotFoundError(f"Function '{name}' not found") if name in self._base_function_names: - raise ValueError( + raise FunctionNotDynamicError( f"Cannot delete '{name}': not a dynamically registered function", ) del self.functions[name] diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index 26ebeb075..fcf4d139c 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -18,6 +18,9 @@ from singlestoredb.functions.ext.plugin.connection import _recv_exact_py from singlestoredb.functions.ext.plugin.connection import PROTOCOL_VERSION from singlestoredb.functions.ext.plugin.control import dispatch_control_signal +from singlestoredb.functions.ext.plugin.registry import FunctionExistsError +from singlestoredb.functions.ext.plugin.registry import FunctionNotDynamicError +from singlestoredb.functions.ext.plugin.registry import FunctionNotFoundError from singlestoredb.functions.ext.plugin.registry import FunctionRegistry from singlestoredb.utils._lazy_import import get_numpy from singlestoredb.utils._lazy_import import get_pandas @@ -222,7 +225,7 @@ def test_register_args_not_list(self): def test_register_func_exists(self): shared = self._make_shared_registry() - shared.create_function.side_effect = ValueError( + shared.create_function.side_effect = FunctionExistsError( 'Function "f" already exists (use replace=true to overwrite)', ) payload = json.dumps({ @@ -236,7 +239,7 @@ def test_register_func_exists(self): def test_register_replace_base_function(self): shared = self._make_shared_registry() - shared.create_function.side_effect = ValueError( + shared.create_function.side_effect = FunctionNotDynamicError( "Cannot replace 'base_fn': not a dynamically registered function", ) payload = json.dumps({ @@ -276,7 +279,7 @@ def test_delete_missing_function_name(self): def test_delete_nonexistent_function(self): shared = self._make_shared_registry() - shared.delete_function.side_effect = ValueError( + shared.delete_function.side_effect = FunctionNotFoundError( "Function 'no_such' not found", ) payload = json.dumps({'name': 'no_such'}).encode() @@ -288,7 +291,7 @@ def test_delete_nonexistent_function(self): def test_delete_base_function(self): shared = self._make_shared_registry() - shared.delete_function.side_effect = ValueError( + shared.delete_function.side_effect = FunctionNotDynamicError( "Cannot delete 'base_fn': not a dynamically registered function", ) payload = json.dumps({'name': 'base_fn'}).encode() From 0f5974dcba92cf89e2756f7556339c78f7051171 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:04:50 -0500 Subject: [PATCH 06/14] Raise typed errors from SharedRegistry.delete_function SharedRegistry.delete_function previously raised plain ValueError for both "function not found" and "not a dynamically registered function" cases. The @@delete dispatch in control.py classifies failures by exception type, so those failures were falling through to the generic branch and returning DELETE_INVALID_PAYLOAD instead of the intended DELETE_FUNC_NOT_FOUND / DELETE_FUNC_NOT_REGISTERED codes. Raise FunctionNotFoundError and FunctionNotDynamicError to match the underlying FunctionRegistry.delete_function. Add regression tests that exercise @@delete end-to-end against a real SharedRegistry (not a mock) so the typed-code path is covered without mocked side_effects. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/server.py | 11 ++++--- singlestoredb/tests/test_plugin.py | 34 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/server.py b/singlestoredb/functions/ext/plugin/server.py index a3b76b80c..1b0319cca 100644 --- a/singlestoredb/functions/ext/plugin/server.py +++ b/singlestoredb/functions/ext/plugin/server.py @@ -28,6 +28,8 @@ from .connection import _write_all_fd from .connection import handle_connection +from .registry import FunctionNotDynamicError +from .registry import FunctionNotFoundError from .registry import FunctionRegistry logger = logging.getLogger('plugin.server') @@ -131,8 +133,9 @@ def create_function( def delete_function(self, function_name: str) -> None: """Delete a dynamically registered function by name. - Raises ValueError if the function was not dynamically registered - or does not exist at all. + Raises FunctionNotDynamicError if the function exists as a base + (non-dynamic) function, FunctionNotFoundError if it does not + exist at all. """ with self._lock: # Find matching code blocks by parsing signature_json @@ -148,11 +151,11 @@ def delete_function(self, function_name: str) -> None: self._base_registry is not None and function_name in self._base_registry.functions ): - raise ValueError( + raise FunctionNotDynamicError( f"Cannot delete '{function_name}': " f'not a dynamically registered function', ) - raise ValueError( + raise FunctionNotFoundError( f"Function '{function_name}' not found", ) diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index fcf4d139c..a6f5a85b4 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -380,16 +380,46 @@ def test_register_then_delete(self): def test_delete_base_function_errors(self): shared = self._make_real_shared_registry() - with self.assertRaises(ValueError) as ctx: + with self.assertRaises(FunctionNotDynamicError) as ctx: shared.delete_function('base_fn') assert 'not a dynamically registered function' in str(ctx.exception) def test_delete_nonexistent_errors(self): shared = self._make_real_shared_registry() - with self.assertRaises(ValueError) as ctx: + with self.assertRaises(FunctionNotFoundError) as ctx: shared.delete_function('ghost') assert 'not found' in str(ctx.exception) + def test_dispatch_delete_nonexistent_returns_func_not_found(self): + """End-to-end @@delete on a real registry returns the typed code. + + Regression test: prior to the typed-exception fix in + SharedRegistry.delete_function, this scenario fell through to + the generic ValueError branch and returned DELETE_INVALID_PAYLOAD. + """ + shared = self._make_real_shared_registry() + payload = json.dumps({'name': 'ghost'}).encode() + result = dispatch_control_signal('@@delete', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'DELETE_FUNC_NOT_FOUND' + assert 'not found' in body['message'] + + def test_dispatch_delete_base_function_returns_not_registered(self): + """End-to-end @@delete on a base function returns the typed code. + + Regression test: prior to the typed-exception fix in + SharedRegistry.delete_function, this scenario fell through to + the generic ValueError branch and returned DELETE_INVALID_PAYLOAD. + """ + shared = self._make_real_shared_registry() + payload = json.dumps({'name': 'base_fn'}).encode() + result = dispatch_control_signal('@@delete', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'DELETE_FUNC_NOT_REGISTERED' + assert 'not a dynamically registered function' in body['message'] + def test_replace_base_via_shared_rejected(self): shared = self._make_real_shared_registry() sig = json.dumps({ From 65e26487f0cd88a43ba0a3630cb941cfe96c483e Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:10:35 -0500 Subject: [PATCH 07/14] Narrow @@register handler to user-input exception types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catching all Exception in _handle_register hid genuinely unexpected failures (ImportError, AttributeError, registry bugs) under REGISTER_INVALID_PAYLOAD. Narrow to (ValueError, SyntaxError, TypeError) — the types FunctionRegistry.create_function actually raises for user-supplied input — so unrelated infrastructure failures propagate to the outer dispatch_control_signal handler and surface as INTERNAL_ERROR per ADR 0001. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 2 +- singlestoredb/tests/test_plugin.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index c289ec994..02f8c6443 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -169,7 +169,7 @@ def _handle_register( return _err(str(e), 'REGISTER_FUNC_EXISTS') except FunctionNotDynamicError as e: return _err(str(e), 'REGISTER_FUNC_NOT_DYNAMIC') - except Exception as e: + except (ValueError, SyntaxError, TypeError) as e: return _err(str(e), 'REGISTER_INVALID_PAYLOAD') # Notify main process so it can re-fork workers with updated state diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index a6f5a85b4..ce05e3fbb 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -252,6 +252,20 @@ def test_register_replace_base_function(self): assert body['code'] == 'REGISTER_FUNC_NOT_DYNAMIC' assert 'not a dynamically registered function' in body['message'] + def test_register_unexpected_internal_error(self): + shared = self._make_shared_registry() + shared.create_function.side_effect = RuntimeError( + 'simulated infrastructure failure', + ) + payload = json.dumps({ + 'name': 'f', 'args': [], 'returns': [], 'body': 'x', + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'INTERNAL_ERROR' + assert 'simulated infrastructure failure' in body['message'] + def test_delete_missing_payload(self): shared = self._make_shared_registry() result = dispatch_control_signal('@@delete', b'', shared) From 51af7bc54abfcbaca45213cf1fc5be22375a77fc Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:21:20 -0500 Subject: [PATCH 08/14] Address Copilot review feedback on control error codes - Catch UnicodeDecodeError alongside JSONDecodeError in @@register and @@delete handlers so non-UTF8 payloads return *_INVALID_PAYLOAD instead of escaping to INTERNAL_ERROR. - Validate that the @@register "replace" field is a bool; reject non-boolean values with REGISTER_INVALID_PAYLOAD. - Update delete_function docstring to enumerate the actual exception types (ValueError, FunctionNotFoundError, FunctionNotDynamicError). Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 9 +++++++-- singlestoredb/functions/ext/plugin/registry.py | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 02f8c6443..15b25facd 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -125,7 +125,7 @@ def _handle_register( try: body = json.loads(request_data) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: return _err(f'Invalid JSON: {e}', 'REGISTER_INVALID_PAYLOAD') function_name = body.get('name') @@ -155,6 +155,11 @@ def _handle_register( ) replace = body.get('replace', False) + if not isinstance(replace, bool): + return _err( + 'Field "replace" must be a boolean', + 'REGISTER_INVALID_PAYLOAD', + ) # Build signature JSON matching describe-functions schema signature = json.dumps({ @@ -198,7 +203,7 @@ def _handle_delete( try: body = json.loads(request_data) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: return _err(f'Invalid JSON: {e}', 'DELETE_INVALID_PAYLOAD') function_name = body.get('name') diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index 2057d2c6b..d95d75965 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -450,7 +450,11 @@ def delete_function(self, signature_json: str) -> None: element schema (must contain a 'name' field). Currently only the name is used for matching. - Raises ValueError if the function does not exist. + Raises: + ValueError: if the signature JSON is missing a "name" field. + FunctionNotFoundError: if no function with that name is registered. + FunctionNotDynamicError: if the function exists but was not + dynamically registered (e.g., a built-in). """ sig = json.loads(signature_json) name = sig.get('name') From e377598b59edc917470112e6703c0a270f88c98a Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:31:42 -0500 Subject: [PATCH 09/14] Address Copilot review feedback on JSON payload type-checks and base-function hint - @@register and @@delete now reject non-object JSON roots with the appropriate *_INVALID_PAYLOAD code instead of falling through to INTERNAL_ERROR via AttributeError on body.get(). - FunctionRegistry.create_function checks _base_function_names before the generic exists-check, so a base-function name collision raises FunctionNotDynamicError (REGISTER_FUNC_NOT_DYNAMIC) instead of the misleading "use replace=true to overwrite" hint. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 12 ++++++++++++ singlestoredb/functions/ext/plugin/registry.py | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 15b25facd..ceeca68b1 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -128,6 +128,12 @@ def _handle_register( except (json.JSONDecodeError, UnicodeDecodeError) as e: return _err(f'Invalid JSON: {e}', 'REGISTER_INVALID_PAYLOAD') + if not isinstance(body, dict): + return _err( + 'Request body must be a JSON object', + 'REGISTER_INVALID_PAYLOAD', + ) + function_name = body.get('name') if not function_name: return _err( @@ -206,6 +212,12 @@ def _handle_delete( except (json.JSONDecodeError, UnicodeDecodeError) as e: return _err(f'Invalid JSON: {e}', 'DELETE_INVALID_PAYLOAD') + if not isinstance(body, dict): + return _err( + 'Request body must be a JSON object', + 'DELETE_INVALID_PAYLOAD', + ) + function_name = body.get('name') if not function_name: return _err( diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index d95d75965..647146ddd 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -398,18 +398,18 @@ def create_function( 'signature JSON must contain a "name" field', ) - if not replace and func_name in self.functions: - raise FunctionExistsError( - f'Function "{func_name}" already exists ' - f'(use replace=true to overwrite)', - ) - if func_name in self._base_function_names: raise FunctionNotDynamicError( f"Cannot replace '{func_name}': " f'not a dynamically registered function', ) + if not replace and func_name in self.functions: + raise FunctionExistsError( + f'Function "{func_name}" already exists ' + f'(use replace=true to overwrite)', + ) + if replace and func_name in self.functions: del self.functions[func_name] From 68d1627a30a7d7aa4da9068287748373aeb411f9 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:45:51 -0500 Subject: [PATCH 10/14] Fix REGISTER_FUNC_NOT_DYNAMIC misclassification on first-time base-name collision create_function previously raised FunctionNotDynamicError for any collision against a base function, even with replace=False. This violated the ADR-0001 mapping: a first-time @@register colliding with a built-in returned REGISTER_FUNC_NOT_DYNAMIC instead of the correct REGISTER_FUNC_EXISTS. Reorder the guards so without replace any name collision (including base functions) raises FunctionExistsError, and NOT_DYNAMIC is reserved for replace=true against a built-in. Update the exception's docstring to reflect that it covers both replace and delete of non-dynamic functions, and add a regression test. Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/registry.py | 15 ++++++++------- singlestoredb/tests/test_plugin.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index 647146ddd..5cd0e6353 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -146,7 +146,8 @@ class FunctionExistsError(ValueError): class FunctionNotDynamicError(ValueError): - """Raised when replacing/deleting a function that was not dynamically registered.""" + """Raised when an operation targets a function that exists but was not + dynamically registered (e.g., replacing or deleting a built-in).""" class FunctionNotFoundError(ValueError): @@ -398,18 +399,18 @@ def create_function( 'signature JSON must contain a "name" field', ) - if func_name in self._base_function_names: - raise FunctionNotDynamicError( - f"Cannot replace '{func_name}': " - f'not a dynamically registered function', - ) - if not replace and func_name in self.functions: raise FunctionExistsError( f'Function "{func_name}" already exists ' f'(use replace=true to overwrite)', ) + if replace and func_name in self._base_function_names: + raise FunctionNotDynamicError( + f"Cannot replace '{func_name}': " + f'not a dynamically registered function', + ) + if replace and func_name in self.functions: del self.functions[func_name] diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index ce05e3fbb..d3acd7683 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -364,6 +364,23 @@ def test_replace_base_function_rejected(self): reg.create_function(sig, 'return x + 1', replace=True) assert 'not a dynamically registered function' in str(ctx.exception) + def test_register_base_name_without_replace_returns_exists(self): + """Regression: a first-time @@register colliding with a base + function name must raise FunctionExistsError (so the dispatcher + emits REGISTER_FUNC_EXISTS), not FunctionNotDynamicError. The + NOT_DYNAMIC code is reserved for replace=True against a + non-dynamic function, per ADR-0001. + """ + reg = self._make_registry_with_base() + sig = json.dumps({ + 'name': 'base_fn', + 'args': [{'name': 'x', 'dtype': 'int64', 'sql': 'BIGINT'}], + 'returns': [{'name': '', 'dtype': 'int64', 'sql': 'BIGINT'}], + }) + with self.assertRaises(FunctionExistsError) as ctx: + reg.create_function(sig, 'return x + 1', replace=False) + assert 'already exists' in str(ctx.exception) + class TestDeleteFunctionIntegration(unittest.TestCase): """Integration tests for @@delete using a real SharedRegistry.""" From 9339c1d748319b502f189e34299e6a6087b22cdf Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 14:59:57 -0500 Subject: [PATCH 11/14] Surface FunctionNotDynamicError up front for base-name collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder the prechecks in FunctionRegistry.create_function so a collision with a base (built-in) function name raises FunctionNotDynamicError — mapped to REGISTER_FUNC_NOT_DYNAMIC — regardless of the replace flag. Previously, a first-time @@register colliding with a base name and replace=False produced FunctionExistsError with the hint "use replace=true to overwrite", but the next guard rejected replace=True against base names, so the suggested remedy never worked. The user had to make two trips to discover that base names are unavailable. The hint is no longer reachable for base names; replace=True against a genuine dynamic-name collision still works as before. Co-Authored-By: Claude Opus 4.7 --- .../functions/ext/plugin/registry.py | 12 ++++----- singlestoredb/tests/test_plugin.py | 26 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index 5cd0e6353..424387d1f 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -399,18 +399,18 @@ def create_function( 'signature JSON must contain a "name" field', ) + if func_name in self._base_function_names: + raise FunctionNotDynamicError( + f"Cannot register '{func_name}': " + f'name is reserved by a built-in (non-dynamic) function', + ) + if not replace and func_name in self.functions: raise FunctionExistsError( f'Function "{func_name}" already exists ' f'(use replace=true to overwrite)', ) - if replace and func_name in self._base_function_names: - raise FunctionNotDynamicError( - f"Cannot replace '{func_name}': " - f'not a dynamically registered function', - ) - if replace and func_name in self.functions: del self.functions[func_name] diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index d3acd7683..dedee3125 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -360,16 +360,16 @@ def test_replace_base_function_rejected(self): 'args': [{'name': 'x', 'dtype': 'int64', 'sql': 'BIGINT'}], 'returns': [{'name': '', 'dtype': 'int64', 'sql': 'BIGINT'}], }) - with self.assertRaises(ValueError) as ctx: + with self.assertRaises(FunctionNotDynamicError) as ctx: reg.create_function(sig, 'return x + 1', replace=True) - assert 'not a dynamically registered function' in str(ctx.exception) - - def test_register_base_name_without_replace_returns_exists(self): - """Regression: a first-time @@register colliding with a base - function name must raise FunctionExistsError (so the dispatcher - emits REGISTER_FUNC_EXISTS), not FunctionNotDynamicError. The - NOT_DYNAMIC code is reserved for replace=True against a - non-dynamic function, per ADR-0001. + assert 'reserved by a built-in' in str(ctx.exception) + + def test_register_base_name_without_replace_returns_not_dynamic(self): + """A @@register colliding with a base (non-dynamic) function name + must raise FunctionNotDynamicError regardless of ``replace``, so + the dispatcher emits REGISTER_FUNC_NOT_DYNAMIC. Suggesting + ``replace=true`` would be misleading since base names cannot be + overwritten by either path. """ reg = self._make_registry_with_base() sig = json.dumps({ @@ -377,9 +377,9 @@ def test_register_base_name_without_replace_returns_exists(self): 'args': [{'name': 'x', 'dtype': 'int64', 'sql': 'BIGINT'}], 'returns': [{'name': '', 'dtype': 'int64', 'sql': 'BIGINT'}], }) - with self.assertRaises(FunctionExistsError) as ctx: + with self.assertRaises(FunctionNotDynamicError) as ctx: reg.create_function(sig, 'return x + 1', replace=False) - assert 'already exists' in str(ctx.exception) + assert 'reserved by a built-in' in str(ctx.exception) class TestDeleteFunctionIntegration(unittest.TestCase): @@ -458,9 +458,9 @@ def test_replace_base_via_shared_rejected(self): 'args': [{'name': 'x', 'dtype': 'int', 'sql': 'INT'}], 'returns': [{'name': '', 'dtype': 'int', 'sql': 'INT'}], }) - with self.assertRaises(ValueError) as ctx: + with self.assertRaises(FunctionNotDynamicError) as ctx: shared.create_function(sig, 'return x + 1', True) - assert 'not a dynamically registered function' in str(ctx.exception) + assert 'reserved by a built-in' in str(ctx.exception) def test_register_delete_reregister(self): shared = self._make_real_shared_registry() From c08d7310a4f5a4f0fbf9ab7864a7831c551ba869 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 15:14:03 -0500 Subject: [PATCH 12/14] Fix DELETE_INVALID_PAYLOAD misclassification and stale FUNC_NOT_DYNAMIC docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trailing `except ValueError` in `_handle_delete` only catches internal `json.loads` failures on stored signature data — client-side payload checks emit `DELETE_INVALID_PAYLOAD` themselves earlier in the handler. Map these internal failures to `INTERNAL_ERROR` to match the cross-cutting fallback in `dispatch_control_signal`. Also broaden the docstrings for `FunctionNotDynamicError` and `REGISTER_FUNC_NOT_DYNAMIC` to cover registration-time base-name collisions, not just replace/delete (commits 68d1627a / 9339c1d7 added that case). Co-Authored-By: Claude Opus 4.7 --- singlestoredb/functions/ext/plugin/control.py | 10 ++++++---- singlestoredb/functions/ext/plugin/registry.py | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index ceeca68b1..eb10183cb 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -14,8 +14,10 @@ field validation. - ``REGISTER_FUNC_EXISTS`` — function with the same name is already registered and ``replace`` was not requested. -- ``REGISTER_FUNC_NOT_DYNAMIC`` — ``replace`` requested for a function that was - not dynamically registered (e.g., a built-in). +- ``REGISTER_FUNC_NOT_DYNAMIC`` — registration targets a name reserved by a + base/built-in function. This covers both replacing a base function (when + ``replace`` is requested) and registering a new function whose name collides + with a base function (even when ``replace`` is not requested). - ``DELETE_MISSING_PAYLOAD`` — ``@@delete`` called with an empty body. - ``DELETE_INVALID_PAYLOAD`` — ``@@delete`` body failed JSON parsing or field validation. @@ -230,8 +232,8 @@ def _handle_delete( return _err(str(e), 'DELETE_FUNC_NOT_REGISTERED') except FunctionNotFoundError as e: return _err(str(e), 'DELETE_FUNC_NOT_FOUND') - except ValueError as e: - return _err(str(e), 'DELETE_INVALID_PAYLOAD') + except Exception as e: + return _err(str(e), 'INTERNAL_ERROR') # Notify main process so it can re-fork workers with updated state if pipe_write_fd is not None: diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index 424387d1f..495b7ed37 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -146,8 +146,10 @@ class FunctionExistsError(ValueError): class FunctionNotDynamicError(ValueError): - """Raised when an operation targets a function that exists but was not - dynamically registered (e.g., replacing or deleting a built-in).""" + """Raised when an operation (register, replace, or delete) targets a + function name reserved by a base/built-in (non-dynamic) function. This + covers registering a new function whose name collides with a base + function as well as attempts to replace or delete a base function.""" class FunctionNotFoundError(ValueError): From aacae19f4985ecc8237d360fde83306c037d1a2f Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 9 Jun 2026 15:32:42 -0500 Subject: [PATCH 13/14] Classify malformed register/delete payloads as INVALID_PAYLOAD Several client-shape errors were falling through to INTERNAL_ERROR because the exceptions raised deep in code generation (KeyError, AttributeError) were not in the dispatcher's typed catch list: - args/returns items missing 'dtype' raised KeyError - 'body' field as a non-string raised AttributeError on .splitlines() - 'name' field as a non-string raised TypeError on dict membership Add up-front isinstance(str) guards in control.py for name (register and delete) and body (register), mirroring the existing 'replace' boolean check. Also pre-validate the shape of args/returns items in FunctionRegistry.create_function so signature errors surface as ValueError -> REGISTER_INVALID_PAYLOAD instead of INTERNAL_ERROR. Adds regression tests covering each path. --- singlestoredb/functions/ext/plugin/control.py | 12 +++ .../functions/ext/plugin/registry.py | 21 +++++ singlestoredb/tests/test_plugin.py | 94 +++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index eb10183cb..490e881aa 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -141,6 +141,10 @@ def _handle_register( return _err( 'Missing required field: name', 'REGISTER_INVALID_PAYLOAD', ) + if not isinstance(function_name, str): + return _err( + 'Field "name" must be a string', 'REGISTER_INVALID_PAYLOAD', + ) args = body.get('args') if not isinstance(args, list): @@ -161,6 +165,10 @@ def _handle_register( return _err( 'Missing required field: body', 'REGISTER_INVALID_PAYLOAD', ) + if not isinstance(func_body, str): + return _err( + 'Field "body" must be a string', 'REGISTER_INVALID_PAYLOAD', + ) replace = body.get('replace', False) if not isinstance(replace, bool): @@ -225,6 +233,10 @@ def _handle_delete( return _err( 'Missing required field: name', 'DELETE_INVALID_PAYLOAD', ) + if not isinstance(function_name, str): + return _err( + 'Field "name" must be a string', 'DELETE_INVALID_PAYLOAD', + ) try: shared_registry.delete_function(function_name) diff --git a/singlestoredb/functions/ext/plugin/registry.py b/singlestoredb/functions/ext/plugin/registry.py index 495b7ed37..eeb5717f9 100644 --- a/singlestoredb/functions/ext/plugin/registry.py +++ b/singlestoredb/functions/ext/plugin/registry.py @@ -401,6 +401,27 @@ def create_function( 'signature JSON must contain a "name" field', ) + for kind in ('args', 'returns'): + items = sig.get(kind, []) + if not isinstance(items, list): + raise ValueError(f'"{kind}" must be a list') + for i, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError( + f'"{kind}[{i}]" must be a JSON object', + ) + dtype = item.get('dtype') + if not isinstance(dtype, str): + raise ValueError( + f'"{kind}[{i}].dtype" must be a string', + ) + if kind == 'args': + name = item.get('name') + if not isinstance(name, str) or not name: + raise ValueError( + f'"{kind}[{i}].name" must be a non-empty string', + ) + if func_name in self._base_function_names: raise FunctionNotDynamicError( f"Cannot register '{func_name}': " diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index dedee3125..810fac1ae 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -223,6 +223,62 @@ def test_register_args_not_list(self): assert body['code'] == 'REGISTER_INVALID_PAYLOAD' assert 'args' in body['message'] + def test_register_name_not_string(self): + shared = self._make_shared_registry() + payload = json.dumps({ + 'name': 123, 'args': [], 'returns': [], 'body': 'return 1', + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'name' in body['message'] + + def test_register_body_not_string(self): + shared = self._make_shared_registry() + payload = json.dumps({ + 'name': 'f', 'args': [], 'returns': [], 'body': 12345, + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'body' in body['message'] + + def test_register_arg_missing_dtype(self): + """An arg dict missing 'dtype' is a client-shape error and must + surface as REGISTER_INVALID_PAYLOAD, not INTERNAL_ERROR.""" + shared = self._make_shared_registry() + shared.create_function.side_effect = ValueError( + '"args[0].dtype" must be a string', + ) + payload = json.dumps({ + 'name': 'f', 'args': [{'name': 'x'}], 'returns': [], + 'body': 'return 1', + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'dtype' in body['message'] + + def test_register_return_missing_dtype(self): + """A returns dict missing 'dtype' is a client-shape error and must + surface as REGISTER_INVALID_PAYLOAD, not INTERNAL_ERROR.""" + shared = self._make_shared_registry() + shared.create_function.side_effect = ValueError( + '"returns[0].dtype" must be a string', + ) + payload = json.dumps({ + 'name': 'f', 'args': [], 'returns': [{'name': ''}], + 'body': 'return 1', + }).encode() + result = dispatch_control_signal('@@register', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'REGISTER_INVALID_PAYLOAD' + assert 'dtype' in body['message'] + def test_register_func_exists(self): shared = self._make_shared_registry() shared.create_function.side_effect = FunctionExistsError( @@ -291,6 +347,17 @@ def test_delete_missing_function_name(self): assert body['code'] == 'DELETE_INVALID_PAYLOAD' assert 'name' in body['message'] + def test_delete_name_not_string(self): + """A non-string name (e.g. dict) must surface as + DELETE_INVALID_PAYLOAD, not INTERNAL_ERROR.""" + shared = self._make_shared_registry() + payload = json.dumps({'name': {'x': 1}}).encode() + result = dispatch_control_signal('@@delete', payload, shared) + assert result.ok is False + body = json.loads(result.data) + assert body['code'] == 'DELETE_INVALID_PAYLOAD' + assert 'name' in body['message'] + def test_delete_nonexistent_function(self): shared = self._make_shared_registry() shared.delete_function.side_effect = FunctionNotFoundError( @@ -364,6 +431,33 @@ def test_replace_base_function_rejected(self): reg.create_function(sig, 'return x + 1', replace=True) assert 'reserved by a built-in' in str(ctx.exception) + def test_create_function_arg_missing_dtype_raises_value_error(self): + reg = FunctionRegistry() + sig = json.dumps({ + 'name': 'f', 'args': [{'name': 'x'}], 'returns': [], + }) + with self.assertRaises(ValueError) as ctx: + reg.create_function(sig, 'return 1', replace=False) + assert 'dtype' in str(ctx.exception) + + def test_create_function_return_missing_dtype_raises_value_error(self): + reg = FunctionRegistry() + sig = json.dumps({ + 'name': 'f', 'args': [], 'returns': [{'name': ''}], + }) + with self.assertRaises(ValueError) as ctx: + reg.create_function(sig, 'return 1', replace=False) + assert 'dtype' in str(ctx.exception) + + def test_create_function_arg_item_not_dict_raises_value_error(self): + reg = FunctionRegistry() + sig = json.dumps({ + 'name': 'f', 'args': [1, 2], 'returns': [], + }) + with self.assertRaises(ValueError) as ctx: + reg.create_function(sig, 'return 1', replace=False) + assert 'args[0]' in str(ctx.exception) + def test_register_base_name_without_replace_returns_not_dynamic(self): """A @@register colliding with a base (non-dynamic) function name must raise FunctionNotDynamicError regardless of ``replace``, so From 0cd3190a9aed7967ccac74c28f7089341c87d9f9 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Wed, 10 Jun 2026 09:39:01 -0500 Subject: [PATCH 14/14] Rename plugin error JSON field from 'message' back to 'error' Restore the previous field name for backward compatibility with consumers that parse the 'error' key from control-signal error responses. --- singlestoredb/functions/ext/plugin/control.py | 6 +-- singlestoredb/tests/test_plugin.py | 48 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/singlestoredb/functions/ext/plugin/control.py b/singlestoredb/functions/ext/plugin/control.py index 490e881aa..a26a9ae57 100644 --- a/singlestoredb/functions/ext/plugin/control.py +++ b/singlestoredb/functions/ext/plugin/control.py @@ -3,7 +3,7 @@ Matches the Rust wasm-udf-server's dispatch_control_signal behavior, including the structured-error-code shape from ADR 0001 -(``{"message": "...", "code": "SCREAMING_SNAKE"}`` on errors). The authoritative +(``{"error": "...", "code": "SCREAMING_SNAKE"}`` on errors). The authoritative catalog of codes lives in ADR 0001 in the ``wasm-udf-server`` repo; the codes emitted from this module are: @@ -53,7 +53,7 @@ class ControlResult: # JSON response. On success (``ok=True``) this is a handler-specific # document such as ``{"status":"ok"}`` or ``{"functions":[...]}``. On # failure (``ok=False``) this is the ADR 0001 error shape - # ``{"message":"...","code":"..."}``. + # ``{"error":"...","code":"..."}``. data: str @@ -61,7 +61,7 @@ def _err(message: str, code: str) -> ControlResult: """Build an error ControlResult with the ADR 0001 JSON shape.""" return ControlResult( ok=False, - data=json.dumps({'message': message, 'code': code}), + data=json.dumps({'error': message, 'code': code}), ) diff --git a/singlestoredb/tests/test_plugin.py b/singlestoredb/tests/test_plugin.py index 810fac1ae..000a13e1e 100644 --- a/singlestoredb/tests/test_plugin.py +++ b/singlestoredb/tests/test_plugin.py @@ -138,7 +138,7 @@ def test_unknown_signal(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'UNKNOWN_SIGNAL' - assert 'Unknown control signal' in body['message'] + assert 'Unknown control signal' in body['error'] def test_internal_error_on_handler_failure(self): shared = self._make_shared_registry() @@ -151,7 +151,7 @@ def test_internal_error_on_handler_failure(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'INTERNAL_ERROR' - assert 'boom' in body['message'] + assert 'boom' in body['error'] def test_register_missing_payload(self): shared = self._make_shared_registry() @@ -159,7 +159,7 @@ def test_register_missing_payload(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_MISSING_PAYLOAD' - assert 'Missing registration payload' in body['message'] + assert 'Missing registration payload' in body['error'] def test_register_invalid_json(self): shared = self._make_shared_registry() @@ -167,7 +167,7 @@ def test_register_invalid_json(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'Invalid JSON' in body['message'] + assert 'Invalid JSON' in body['error'] def test_register_missing_function_name(self): shared = self._make_shared_registry() @@ -176,7 +176,7 @@ def test_register_missing_function_name(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'name' in body['message'] + assert 'name' in body['error'] def test_register_missing_args(self): shared = self._make_shared_registry() @@ -187,7 +187,7 @@ def test_register_missing_args(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'args' in body['message'] + assert 'args' in body['error'] def test_register_missing_returns(self): shared = self._make_shared_registry() @@ -198,7 +198,7 @@ def test_register_missing_returns(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'returns' in body['message'] + assert 'returns' in body['error'] def test_register_missing_body(self): shared = self._make_shared_registry() @@ -209,7 +209,7 @@ def test_register_missing_body(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'body' in body['message'] + assert 'body' in body['error'] def test_register_args_not_list(self): shared = self._make_shared_registry() @@ -221,7 +221,7 @@ def test_register_args_not_list(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'args' in body['message'] + assert 'args' in body['error'] def test_register_name_not_string(self): shared = self._make_shared_registry() @@ -232,7 +232,7 @@ def test_register_name_not_string(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'name' in body['message'] + assert 'name' in body['error'] def test_register_body_not_string(self): shared = self._make_shared_registry() @@ -243,7 +243,7 @@ def test_register_body_not_string(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'body' in body['message'] + assert 'body' in body['error'] def test_register_arg_missing_dtype(self): """An arg dict missing 'dtype' is a client-shape error and must @@ -260,7 +260,7 @@ def test_register_arg_missing_dtype(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'dtype' in body['message'] + assert 'dtype' in body['error'] def test_register_return_missing_dtype(self): """A returns dict missing 'dtype' is a client-shape error and must @@ -277,7 +277,7 @@ def test_register_return_missing_dtype(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_INVALID_PAYLOAD' - assert 'dtype' in body['message'] + assert 'dtype' in body['error'] def test_register_func_exists(self): shared = self._make_shared_registry() @@ -291,7 +291,7 @@ def test_register_func_exists(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_FUNC_EXISTS' - assert 'already exists' in body['message'] + assert 'already exists' in body['error'] def test_register_replace_base_function(self): shared = self._make_shared_registry() @@ -306,7 +306,7 @@ def test_register_replace_base_function(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'REGISTER_FUNC_NOT_DYNAMIC' - assert 'not a dynamically registered function' in body['message'] + assert 'not a dynamically registered function' in body['error'] def test_register_unexpected_internal_error(self): shared = self._make_shared_registry() @@ -320,7 +320,7 @@ def test_register_unexpected_internal_error(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'INTERNAL_ERROR' - assert 'simulated infrastructure failure' in body['message'] + assert 'simulated infrastructure failure' in body['error'] def test_delete_missing_payload(self): shared = self._make_shared_registry() @@ -328,7 +328,7 @@ def test_delete_missing_payload(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_MISSING_PAYLOAD' - assert 'Missing deletion payload' in body['message'] + assert 'Missing deletion payload' in body['error'] def test_delete_invalid_json(self): shared = self._make_shared_registry() @@ -336,7 +336,7 @@ def test_delete_invalid_json(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_INVALID_PAYLOAD' - assert 'Invalid JSON' in body['message'] + assert 'Invalid JSON' in body['error'] def test_delete_missing_function_name(self): shared = self._make_shared_registry() @@ -345,7 +345,7 @@ def test_delete_missing_function_name(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_INVALID_PAYLOAD' - assert 'name' in body['message'] + assert 'name' in body['error'] def test_delete_name_not_string(self): """A non-string name (e.g. dict) must surface as @@ -356,7 +356,7 @@ def test_delete_name_not_string(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_INVALID_PAYLOAD' - assert 'name' in body['message'] + assert 'name' in body['error'] def test_delete_nonexistent_function(self): shared = self._make_shared_registry() @@ -368,7 +368,7 @@ def test_delete_nonexistent_function(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_FUNC_NOT_FOUND' - assert 'not found' in body['message'] + assert 'not found' in body['error'] def test_delete_base_function(self): shared = self._make_shared_registry() @@ -380,7 +380,7 @@ def test_delete_base_function(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_FUNC_NOT_REGISTERED' - assert 'not a dynamically registered function' in body['message'] + assert 'not a dynamically registered function' in body['error'] def test_delete_success(self): shared = self._make_shared_registry() @@ -528,7 +528,7 @@ def test_dispatch_delete_nonexistent_returns_func_not_found(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_FUNC_NOT_FOUND' - assert 'not found' in body['message'] + assert 'not found' in body['error'] def test_dispatch_delete_base_function_returns_not_registered(self): """End-to-end @@delete on a base function returns the typed code. @@ -543,7 +543,7 @@ def test_dispatch_delete_base_function_returns_not_registered(self): assert result.ok is False body = json.loads(result.data) assert body['code'] == 'DELETE_FUNC_NOT_REGISTERED' - assert 'not a dynamically registered function' in body['message'] + assert 'not a dynamically registered function' in body['error'] def test_replace_base_via_shared_rejected(self): shared = self._make_real_shared_registry()