This is an automated email from the ASF dual-hosted git repository.
HappenLee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new e870abeee7c [Fix](test) make pyudf ut deterministic and meaningful
(#67352)
e870abeee7c is described below
commit e870abeee7c4ec066143261882887015149e7f69
Author: linrrarity <[email protected]>
AuthorDate: Thu Sep 10 18:23:59 2026 +0800
[Fix](test) make pyudf ut deterministic and meaningful (#67352)
Problem Summary:
Some Python UDF BE unit tests were flaky and provided limited
verification value. Several tests launched shell-based fake Python
processes that only created Unix socket files or stayed alive for a
fixed duration. These tests exercised process-management branches but
did not verify that Doris could start the production Python server,
establish an Arrow Flight connection, or execute an actual UDF.
This PR:
- Removes or rewrites low-value Python UDF tests that only exercised
implementation details or line coverage.
- Keeps meaningful coverage for process startup failures, shutdown,
recovery, pool initialization, and concurrent process selection.
- Fixes the race in the least-shared-process test by waiting until the
complete process pool is available before checking process distribution.
- Adds an end-to-end BE unit test that:
- Creates a Python runtime through the production venv scanner.
- Starts the real `python_server.py`.
- Connects through Arrow Flight.
- Executes a nullable inline scalar UDF.
- Verifies the returned values and null propagation.
- Adds `PYTHON_UDF_TEST_INTERPRETER` as the only Python interpreter
configuration for this test. It defaults to `/usr/bin/python`, matching
the community P0 pipeline, and can be overridden locally in
`custom_env.sh`.
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [ ] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
---
be/src/udf/python/python_server.cpp | 55 +++-
be/src/udf/python/python_server.h | 8 +-
be/test/udf/python/python_env_test.cpp | 179 ++++-------
be/test/udf/python/python_server_test.cpp | 398 +++++++++++++------------
be/test/udf/python/python_udf_meta_test.cpp | 144 ++++-----
be/test/udf/python/python_udf_runtime_test.cpp | 225 ++------------
6 files changed, 397 insertions(+), 612 deletions(-)
diff --git a/be/src/udf/python/python_server.cpp
b/be/src/udf/python/python_server.cpp
index cf78591a2ef..2d06caa3110 100644
--- a/be/src/udf/python/python_server.cpp
+++ b/be/src/udf/python/python_server.cpp
@@ -36,6 +36,7 @@
#include "arrow/flight/client.h"
#include "common/config.h"
#include "common/status.h"
+#include "cpp/sync_point.h"
#include "runtime/thread_context.h"
#include "udf/python/python_udaf_client.h"
#include "udf/python/python_udf_client.h"
@@ -91,17 +92,37 @@ std::vector<ProcessPtr>
PythonServerManager::process_pool_snapshot_for_test(
return versioned_pool->processes;
}
-bool PythonServerManager::process_pool_is_initializing_for_test(const
PythonVersion& version) {
- auto versioned_pool = _get_or_create_process_pool(version).value();
- std::lock_guard<std::mutex> lock(versioned_pool->mutex);
- return versioned_pool->state == PoolState::INITIALIZING;
-}
-
bool PythonServerManager::process_pool_is_initialized_for_test(const
PythonVersion& version) {
auto versioned_pool = _get_or_create_process_pool(version).value();
std::lock_guard<std::mutex> lock(versioned_pool->mutex);
return versioned_pool->state == PoolState::INITIALIZED;
}
+
+bool PythonServerManager::wait_for_process_pool_initialized_for_test(
+ const PythonVersion& version, std::chrono::milliseconds timeout) {
+ auto versioned_pool_result = _get_or_create_process_pool(version);
+ if (!versioned_pool_result.has_value()) {
+ return false;
+ }
+ auto versioned_pool = versioned_pool_result.value();
+ std::unique_lock<std::mutex> lock(versioned_pool->mutex);
+ return versioned_pool->cv.wait_for(lock, timeout, [&versioned_pool]() {
+ return versioned_pool->state == PoolState::INITIALIZED;
+ });
+}
+
+bool
PythonServerManager::wait_for_process_pool_initialization_finished_for_test(
+ const PythonVersion& version, std::chrono::milliseconds timeout) {
+ auto versioned_pool_result = _get_or_create_process_pool(version);
+ if (!versioned_pool_result.has_value()) {
+ return false;
+ }
+ auto versioned_pool = versioned_pool_result.value();
+ std::unique_lock<std::mutex> lock(versioned_pool->mutex);
+ return versioned_pool->cv.wait_for(lock, timeout, [&versioned_pool]() {
+ return versioned_pool->state != PoolState::INITIALIZING;
+ });
+}
#endif
bool PythonServerManager::_select_alive_process_from_pool(const
std::vector<ProcessPtr>& pool,
@@ -150,6 +171,10 @@ PythonServerManager::_ensure_pool_initialized(const
PythonVersion& version) {
auto versioned_pool = versioned_pool_result.value();
const int max_pool_size = config::max_python_process_num > 0 ?
config::max_python_process_num
:
CpuInfo::num_cores();
+ auto process_pool_init_timeout = PROCESS_POOL_INIT_TIMEOUT;
+ TEST_SYNC_POINT_CALLBACK(
+
"PythonServerManager::_ensure_pool_initialized:process_pool_init_timeout",
+ &process_pool_init_timeout);
std::unique_lock<std::mutex> lock(versioned_pool->mutex);
if (versioned_pool->state == PoolState::INITIALIZED) {
@@ -162,17 +187,21 @@ PythonServerManager::_ensure_pool_initialized(const
PythonVersion& version) {
versioned_pool->has_available_process = false;
versioned_pool->processes.resize(max_pool_size);
auto init_finished_count = std::make_shared<std::atomic<int>>(0);
+ TEST_SYNC_POINT_CALLBACK(
+
"PythonServerManager::_ensure_pool_initialized:generation_started", &version,
+ init_finished_count.get());
LOG(INFO) << "Initializing Python process pool for version " <<
version.to_string()
<< " with " << max_pool_size << " processes
(config::max_python_process_num="
<< config::max_python_process_num << ", CPU cores=" <<
CpuInfo::num_cores()
<< ")";
- std::thread([this, versioned_pool, init_finished_count,
max_pool_size]() {
+ std::thread([this, versioned_pool, init_finished_count,
max_pool_size,
+ process_pool_init_timeout]() {
SCOPED_INIT_THREAD_CONTEXT();
std::unique_lock<std::mutex> lock(versioned_pool->mutex);
versioned_pool->cv.wait_for(
- lock, PROCESS_POOL_INIT_TIMEOUT,
+ lock, process_pool_init_timeout,
[&versioned_pool, init_finished_count,
max_pool_size]() {
return versioned_pool->state !=
PoolState::INITIALIZING ||
init_finished_count->load(std::memory_order_acquire) >=
@@ -223,13 +252,16 @@ PythonServerManager::_ensure_pool_initialized(const
PythonVersion& version) {
if (process_to_shutdown) {
process_to_shutdown->shutdown();
}
+ TEST_SYNC_POINT_CALLBACK(
+
"PythonServerManager::_ensure_pool_initialized:init_worker_finished",
+ &version, init_finished_count.get());
}).detach();
}
}
// Wait only for the first usable process. INITIALIZED is set later by
the last init worker
// after every slot has attempted initialization.
- versioned_pool->cv.wait_for(lock, PROCESS_POOL_INIT_TIMEOUT,
[&versioned_pool]() {
+ versioned_pool->cv.wait_for(lock, process_pool_init_timeout,
[&versioned_pool]() {
return versioned_pool->has_available_process ||
versioned_pool->state == PoolState::STOPPED ||
versioned_pool->state != PoolState::INITIALIZING;
@@ -243,7 +275,7 @@ PythonServerManager::_ensure_pool_initialized(const
PythonVersion& version) {
return ResultError(Status::Error<ErrorCode::SERVICE_UNAVAILABLE>(
"Failed to initialize Python process pool for version {}: no
process became available "
"within {} ms",
- version.to_string(), PROCESS_POOL_INIT_TIMEOUT.count()));
+ version.to_string(), process_pool_init_timeout.count()));
}
Status PythonServerManager::_get_process(
@@ -346,6 +378,9 @@ Status PythonServerManager::fork(const PythonVersion&
version, ProcessPtr* proce
boost::process::environment env = boost::this_process::environment();
boost::process::ipstream child_output;
+ // Test synchronization belongs before child creation so it cannot consume
readiness timeout.
+ TEST_SYNC_POINT_CALLBACK("PythonServerManager::fork:before_process_start",
&version);
+
try {
boost::process::child c(
python_executable_path, args, boost::process::std_out >
child_output,
diff --git a/be/src/udf/python/python_server.h
b/be/src/udf/python/python_server.h
index 5ee763d5f52..c72a2db43d1 100644
--- a/be/src/udf/python/python_server.h
+++ b/be/src/udf/python/python_server.h
@@ -67,10 +67,14 @@ public:
std::vector<ProcessPtr> process_pool_snapshot_for_test(const
PythonVersion& version);
- bool process_pool_is_initializing_for_test(const PythonVersion& version);
-
bool process_pool_is_initialized_for_test(const PythonVersion& version);
+ bool wait_for_process_pool_initialized_for_test(const PythonVersion&
version,
+ std::chrono::milliseconds
timeout);
+
+ bool wait_for_process_pool_initialization_finished_for_test(const
PythonVersion& version,
+
std::chrono::milliseconds timeout);
+
Status broadcast_action_to_processes_for_test(const std::string&
action_type,
const std::string& body,
const std::string& log_name)
{
diff --git a/be/test/udf/python/python_env_test.cpp
b/be/test/udf/python/python_env_test.cpp
index 361d3c59abc..4e48ee71d7a 100644
--- a/be/test/udf/python/python_env_test.cpp
+++ b/be/test/udf/python/python_env_test.cpp
@@ -23,6 +23,7 @@
#include <filesystem>
#include <fstream>
#include <string>
+#include <unordered_map>
#include <vector>
namespace doris {
@@ -76,37 +77,6 @@ protected:
// PythonVersion tests
// ============================================================================
-TEST_F(PythonEnvTest, PythonVersionDefaultConstruction) {
- PythonVersion pv;
- EXPECT_TRUE(pv.full_version.empty());
- EXPECT_TRUE(pv.base_path.empty());
- EXPECT_TRUE(pv.executable_path.empty());
-}
-
-TEST_F(PythonEnvTest, PythonVersionExplicitConstruction) {
- PythonVersion pv("3.9.16", "/opt/python", "/opt/python/bin/python3");
- EXPECT_EQ(pv.full_version, "3.9.16");
- EXPECT_EQ(pv.base_path, "/opt/python");
- EXPECT_EQ(pv.executable_path, "/opt/python/bin/python3");
-}
-
-TEST_F(PythonEnvTest, PythonVersionEquality) {
- PythonVersion pv1("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonVersion pv2("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonVersion pv3("3.10.0", "/opt/python", "/opt/python/bin/python3");
- PythonVersion pv4("3.9.16", "/different/path", "/opt/python/bin/python3");
-
- EXPECT_EQ(pv1, pv2);
- EXPECT_FALSE(pv1 == pv3);
- EXPECT_FALSE(pv1 == pv4);
-}
-
-TEST_F(PythonEnvTest, PythonVersionGetters) {
- PythonVersion pv("3.9.16", "/opt/python", "/opt/python/bin/python3");
- EXPECT_EQ(pv.get_base_path(), "/opt/python");
- EXPECT_EQ(pv.get_executable_path(), "/opt/python/bin/python3");
-}
-
TEST_F(PythonEnvTest, PythonVersionIsValidEmpty) {
PythonVersion pv;
EXPECT_FALSE(pv.is_valid());
@@ -138,50 +108,34 @@ TEST_F(PythonEnvTest,
PythonVersionIsValidWithExistingPaths) {
fs::permissions(exec_path, fs::perms::owner_all);
PythonVersion pv("3.9.16", base_path, exec_path);
- // is_valid() also checks that extract_python_version works, which
requires a real python
- // So this will fail on fake executable, but we verify paths exist
- EXPECT_TRUE(fs::exists(base_path));
- EXPECT_TRUE(fs::exists(exec_path));
-}
-
-TEST_F(PythonEnvTest, PythonVersionToString) {
- PythonVersion pv("3.9.16", "/opt/python", "/opt/python/bin/python3");
- std::string str = pv.to_string();
- EXPECT_TRUE(str.find("3.9.16") != std::string::npos);
- EXPECT_TRUE(str.find("/opt/python") != std::string::npos);
- EXPECT_TRUE(str.find("/opt/python/bin/python3") != std::string::npos);
-}
-
-TEST_F(PythonEnvTest, PythonVersionHash) {
- PythonVersion pv1("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonVersion pv2("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonVersion pv3("3.10.0", "/opt/python", "/opt/python/bin/python3");
-
- std::hash<PythonVersion> hasher;
- EXPECT_EQ(hasher(pv1), hasher(pv2));
- // Different versions should (very likely) have different hashes
- EXPECT_NE(hasher(pv1), hasher(pv3));
+ EXPECT_TRUE(pv.is_valid());
}
-// ============================================================================
-// PythonEnvironment tests
-// ============================================================================
-
-TEST_F(PythonEnvTest, PythonEnvironmentConstruction) {
- PythonVersion pv("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonEnvironment env("test_env", pv);
+TEST_F(PythonEnvTest, PythonVersionWorksAsProcessPoolMapKey) {
+ PythonVersion baseline("3.9.16", "/opt/python", "/opt/python/bin/python3");
+ PythonVersion equal_key("3.9.16", "/opt/python",
"/opt/python/bin/python3");
+ PythonVersion different_version("3.10.0", "/opt/python",
"/opt/python/bin/python3");
+ PythonVersion different_base("3.9.16", "/different/path",
"/opt/python/bin/python3");
+ PythonVersion different_executable("3.9.16", "/opt/python",
"/different/path/bin/python3");
- EXPECT_EQ(env.env_name, "test_env");
- EXPECT_EQ(env.python_version.full_version, "3.9.16");
-}
+ // PythonServerManager keys process pools by the complete PythonVersion.
Equal keys must find
+ // the same pool, while a change in any identity field must select a
different pool.
+ EXPECT_EQ(baseline, equal_key);
+ EXPECT_NE(baseline, different_version);
+ EXPECT_NE(baseline, different_base);
+ EXPECT_NE(baseline, different_executable);
-TEST_F(PythonEnvTest, PythonEnvironmentToString) {
- PythonVersion pv("3.9.16", "/opt/python", "/opt/python/bin/python3");
- PythonEnvironment env("test_env", pv);
+ std::unordered_map<PythonVersion, int> pools;
+ pools.emplace(baseline, 1);
+ pools.emplace(different_version, 2);
+ pools.emplace(different_base, 3);
+ pools.emplace(different_executable, 4);
- std::string str = env.to_string();
- EXPECT_TRUE(str.find("test_env") != std::string::npos);
- EXPECT_TRUE(str.find("3.9.16") != std::string::npos);
+ ASSERT_EQ(pools.size(), 4);
+ EXPECT_EQ(pools.at(equal_key), 1);
+ EXPECT_EQ(pools.at(different_version), 2);
+ EXPECT_EQ(pools.at(different_base), 3);
+ EXPECT_EQ(pools.at(different_executable), 4);
}
TEST_F(PythonEnvTest, PythonEnvironmentIsValidWithInvalidVersion) {
@@ -231,6 +185,44 @@ TEST_F(PythonEnvTest,
ScanFromVenvRootPathNonExistentInterpreter) {
EXPECT_TRUE(status.to_string().find("Interpreter path not found") !=
std::string::npos);
}
+TEST_F(PythonEnvTest, ScanFromVenvRootPathCreatesAndValidatesEnvironment) {
+ const std::string root_path = test_dir_ + "/venv_root";
+ const std::string interpreter_path = test_dir_ + "/python3.11";
+ fs::create_directories(root_path);
+
+ // Model the two interpreter operations used by the production VENV path:
version discovery
+ // and `python -m venv`. The generated environment also answers --version,
so the scanner must
+ // complete its normal validation instead of accepting a directory shape
alone.
+ {
+ std::ofstream ofs(interpreter_path);
+ ofs << "#!/bin/bash\n";
+ ofs << "if [ \"$1\" = \"--version\" ]; then\n";
+ ofs << " echo 'Python 3.11.9'\n";
+ ofs << " exit 0\n";
+ ofs << "fi\n";
+ ofs << "if [ \"$1\" = \"-m\" ] && [ \"$2\" = \"venv\" ] && "
+ "[ \"$3\" = \"--system-site-packages\" ]; then\n";
+ ofs << " mkdir -p \"$4/bin\" \"$4/lib/python3.11/site-packages\"\n";
+ ofs << " cp \"$0\" \"$4/bin/python\"\n";
+ ofs << " exit 0\n";
+ ofs << "fi\n";
+ ofs << "exit 1\n";
+ }
+ fs::permissions(interpreter_path, fs::perms::owner_all);
+
+ std::vector<PythonEnvironment> envs;
+ Status status =
+ PythonEnvironment::scan_from_venv_root_path(root_path,
{interpreter_path}, &envs);
+
+ ASSERT_TRUE(status.ok()) << status.to_string();
+ ASSERT_EQ(envs.size(), 1);
+ EXPECT_EQ(envs[0].env_name, "python3.11.9");
+ EXPECT_EQ(envs[0].python_version.full_version, "3.11.9");
+ EXPECT_EQ(envs[0].python_version.base_path, root_path + "/python3.11.9");
+ EXPECT_EQ(envs[0].python_version.executable_path, root_path +
"/python3.11.9/bin/python");
+ EXPECT_TRUE(fs::is_directory(root_path +
"/python3.11.9/lib/python3.11/site-packages"));
+}
+
// ============================================================================
// PythonEnvScanner tests
// ============================================================================
@@ -251,38 +243,12 @@ TEST_F(PythonEnvTest, PythonEnvScannerGetVersionNotFound)
{
EXPECT_TRUE(status.to_string().find("not found available version") !=
std::string::npos);
}
-TEST_F(PythonEnvTest, CondaEnvScannerProperties) {
- CondaEnvScanner scanner(test_dir_);
- EXPECT_EQ(scanner.env_type(), PythonEnvType::CONDA);
- EXPECT_EQ(scanner.root_path(), test_dir_);
-}
-
-TEST_F(PythonEnvTest, CondaEnvScannerToString) {
- CondaEnvScanner scanner(test_dir_);
- std::string str = scanner.to_string();
- EXPECT_TRUE(str.find("Conda environments") != std::string::npos);
-}
-
TEST_F(PythonEnvTest, CondaEnvScannerScanNonExistent) {
CondaEnvScanner scanner("/non/existent/path");
Status status = scanner.scan();
EXPECT_FALSE(status.ok());
}
-TEST_F(PythonEnvTest, VenvEnvScannerProperties) {
- std::vector<std::string> paths = {"/usr/bin/python3"};
- VenvEnvScanner scanner(test_dir_, paths);
- EXPECT_EQ(scanner.env_type(), PythonEnvType::VENV);
- EXPECT_EQ(scanner.root_path(), test_dir_);
-}
-
-TEST_F(PythonEnvTest, VenvEnvScannerToString) {
- std::vector<std::string> paths = {"/usr/bin/python3"};
- VenvEnvScanner scanner(test_dir_, paths);
- std::string str = scanner.to_string();
- EXPECT_TRUE(str.find("Venv environments") != std::string::npos);
-}
-
TEST_F(PythonEnvTest, VenvEnvScannerScanNonExistentInterpreter) {
std::vector<std::string> paths = {"/non/existent/python3"};
VenvEnvScanner scanner(test_dir_, paths);
@@ -327,25 +293,6 @@ TEST_F(PythonEnvTest,
PythonVersionManagerInitUnsupportedType) {
// Tests covering additional paths using fake Python scripts
// ============================================================================
-TEST_F(PythonEnvTest, PythonVersionIsValidWithFakePython) {
- // Create a fake Python script that prints "Python 3.9.16"
- std::string base_path = test_dir_ + "/fake_python";
- std::string bin_path = base_path + "/bin";
- std::string exec_path = bin_path + "/python3";
- fs::create_directories(bin_path);
-
- {
- std::ofstream ofs(exec_path);
- ofs << "#!/bin/bash\n";
- ofs << "echo 'Python 3.9.16'\n";
- }
- fs::permissions(exec_path, fs::perms::owner_all);
-
- PythonVersion pv("3.9.16", base_path, exec_path);
- // Now is_valid() should pass because the version matches
- EXPECT_TRUE(pv.is_valid());
-}
-
TEST_F(PythonEnvTest, PythonVersionIsValidVersionMismatch) {
// Create a fake Python script that prints a different version
std::string base_path = test_dir_ + "/fake_python2";
@@ -483,7 +430,7 @@ TEST_F(PythonEnvTest, ScanFromCondaRootPathMultipleEnvs) {
EXPECT_EQ(envs.size(), 2);
}
-TEST_F(PythonEnvTest, ScanFromCondaRootPathSkipsInvalidEnvs) {
+TEST_F(PythonEnvTest, ScanFromCondaRootPathFailsOnInvalidEnv) {
// Create a valid and an invalid environment
std::string conda_root = test_dir_ + "/conda_mixed";
diff --git a/be/test/udf/python/python_server_test.cpp
b/be/test/udf/python/python_server_test.cpp
index 3ec463bbe1c..39e6143bc14 100644
--- a/be/test/udf/python/python_server_test.cpp
+++ b/be/test/udf/python/python_server_test.cpp
@@ -26,14 +26,17 @@
#include <filesystem>
#include <fstream>
#include <future>
+#include <optional>
#include <string>
#include <vector>
#include "common/config.h"
#include "common/status.h"
+#include "cpp/sync_point.h"
#include "udf/python/python_env.h"
#include "udf/python/python_udf_client.h"
#include "udf/python/python_udf_meta.h"
+#include "util/defer_op.h"
namespace doris {
@@ -43,7 +46,7 @@ namespace bp = boost::process;
class PythonServerTest : public ::testing::Test {
protected:
std::string test_dir_;
- const char* original_doris_home_ = nullptr;
+ std::optional<std::string> original_doris_home_;
int original_max_python_process_num_ = 0;
void SetUp() override {
@@ -51,7 +54,9 @@ protected:
std::to_string(getpid()) + "_" + std::to_string(rand());
fs::create_directories(test_dir_);
- original_doris_home_ = std::getenv("DORIS_HOME");
+ if (const char* doris_home = std::getenv("DORIS_HOME")) {
+ original_doris_home_ = doris_home;
+ }
original_max_python_process_num_ = config::max_python_process_num;
}
@@ -64,7 +69,7 @@ protected:
}
if (original_doris_home_) {
- setenv("DORIS_HOME", original_doris_home_, 1);
+ setenv("DORIS_HOME", original_doris_home_->c_str(), 1);
} else {
unsetenv("DORIS_HOME");
}
@@ -86,6 +91,7 @@ protected:
ofs << " echo 'Python " << version << "'\n";
ofs << " exit 0\n";
ofs << "fi\n";
+ ofs << "if [ ! -f \"$2\" ]; then exit 2; fi\n";
// Extract socket path prefix from args and create the socket file
// Arg format: -u script.py grpc+unix:///tmp/doris_python_udf
ofs << "SOCKET_PREFIX=\"$3\"\n";
@@ -103,32 +109,6 @@ protected:
return python_path;
}
- std::string create_fake_python_with_delay_and_socket_creation(const
std::string& binary_name,
- const
std::string& version,
- int
delay_ms) {
- std::string bin_dir = test_dir_ + "/bin";
- std::string python_path = bin_dir + "/" + binary_name;
- fs::create_directories(bin_dir);
-
- std::ofstream ofs(python_path);
- ofs << "#!/bin/bash\n";
- ofs << "if [ \"$1\" = \"--version\" ]; then\n";
- ofs << " echo 'Python " << version << "'\n";
- ofs << " exit 0\n";
- ofs << "fi\n";
- ofs << "sleep " << (delay_ms / 1000.0) << "\n";
- ofs << "SOCKET_PREFIX=\"$3\"\n";
- ofs << "SOCKET_BASE=\"${SOCKET_PREFIX#grpc+unix://}\"\n";
- ofs << "SOCKET_FILE=\"${SOCKET_BASE}_$$.sock\"\n";
- ofs << "touch \"$SOCKET_FILE\"\n";
- ofs << "trap 'rm -f \"$SOCKET_FILE\"; exit 0' TERM INT\n";
- ofs << "while true; do sleep 1; done\n";
- ofs.close();
- fs::permissions(python_path, fs::perms::owner_all);
-
- return python_path;
- }
-
std::string create_fake_python_without_socket_creation(const std::string&
binary_name,
const std::string&
version) {
std::string bin_dir = test_dir_ + "/bin";
@@ -141,6 +121,7 @@ protected:
ofs << " echo 'Python " << version << "'\n";
ofs << " exit 0\n";
ofs << "fi\n";
+ ofs << "if [ ! -f \"$2\" ]; then exit 2; fi\n";
ofs << "trap '' TERM\n";
ofs << "while true; do sleep 1; done\n";
ofs.close();
@@ -162,6 +143,7 @@ protected:
ofs << " echo 'Python " << version << "'\n";
ofs << " exit 0\n";
ofs << "fi\n";
+ ofs << "if [ ! -f \"$2\" ]; then exit 2; fi\n";
ofs << "if mkdir \"" << first_start_dir << "\" 2>/dev/null; then\n";
ofs << " trap '' TERM\n";
ofs << " while true; do sleep 1; done\n";
@@ -226,32 +208,6 @@ TEST_F(PythonServerTest, SingletonReturnsSameInstance) {
EXPECT_EQ(&mgr1, &mgr2);
}
-// ============================================================================
-// PythonServerManager::_get_process() - process retrieval test
-// ============================================================================
-
-TEST_F(PythonServerTest, EnsurePoolInitializedCanInitializeEmptyPoolForTest) {
- PythonServerManager mgr;
-
- setup_doris_home();
- std::string python_path =
create_fake_python_with_socket_creation("3.9.16");
- PythonVersion version("3.9.16", test_dir_, python_path);
- config::max_python_process_num = 1;
-
- mgr.set_process_pool_for_test(version, {}, false);
- auto pool_result = mgr._ensure_pool_initialized(version);
- ASSERT_TRUE(pool_result.has_value()) << pool_result.error().to_string();
-
- ProcessPtr process;
- Status status = mgr._get_process(version, pool_result.value(), &process);
-
- EXPECT_TRUE(status.ok()) << status.to_string();
- ASSERT_NE(process, nullptr);
- EXPECT_TRUE(process->is_alive());
-
- mgr.shutdown();
-}
-
// ============================================================================
// PythonServerManager::fork() - process creation test
// ============================================================================
@@ -271,25 +227,21 @@ TEST_F(PythonServerTest,
ForkWithNonExistentPythonReturnsError) {
TEST_F(PythonServerTest, ForkWithMissingFlightServerReturnsError) {
PythonServerManager mgr;
-
- // Set DORIS_HOME to test directory (no flight server script)
- setenv("DORIS_HOME", test_dir_.c_str(), 1);
-
- // Create a fake python executable
- std::string python_path = test_dir_ + "/bin/python3";
- fs::create_directories(test_dir_ + "/bin");
- {
- std::ofstream ofs(python_path);
- ofs << "#!/bin/bash\nexit 1"; // exits immediately
- }
- fs::permissions(python_path, fs::perms::owner_all);
-
+ setup_doris_home();
+ std::string python_path =
create_fake_python_with_socket_creation("3.9.16");
PythonVersion version("3.9.16", test_dir_, python_path);
+ // Prove that the same executable and arguments start correctly while the
production server
+ // entry exists, then remove only that entry to isolate the failure cause.
+ ProcessPtr healthy_process;
+ ASSERT_TRUE(mgr.fork(version, &healthy_process).ok());
+ ASSERT_NE(healthy_process, nullptr);
+ healthy_process->shutdown();
+
+ ASSERT_TRUE(fs::remove(test_dir_ +
"/plugins/python_udf/python_server.py"));
ProcessPtr process;
Status status = mgr.fork(version, &process);
- // Verify: when the flight server script does not exist, fork should fail
EXPECT_FALSE(status.ok());
EXPECT_EQ(process, nullptr);
}
@@ -401,19 +353,86 @@ TEST_F(PythonServerTest,
EnsurePoolInitializedWithInvalidVersionFails) {
result.error().to_string().find("Timed out") !=
std::string::npos);
}
-TEST_F(PythonServerTest,
EnsurePoolInitializedReturnsImmediatelyWhenAllWorkersFail) {
+TEST_F(PythonServerTest, EnsurePoolInitializedRetriesAfterRuntimeIsRepaired) {
+ setup_doris_home();
+ config::max_python_process_num = 1;
+
+ // The first executable starts but never publishes its Flight socket. This
reproduces a real
+ // worker-start failure and verifies that the pool does not remain stuck
in INITIALIZING.
+ std::string python_path =
create_fake_python_without_socket_creation("python3", "3.9.16");
+ PythonVersion version("3.9.16", test_dir_, python_path);
PythonServerManager mgr;
- config::max_python_process_num = 2;
- PythonVersion invalid_version("3.9.16", test_dir_, test_dir_ +
"/missing_python");
+ struct GenerationCompletionLatch {
+ std::mutex mutex;
+ std::condition_variable cv;
+ std::atomic<int>* generation = nullptr;
+ bool worker_finished = false;
+ };
+ auto generation_completion = std::make_shared<GenerationCompletionLatch>();
+ auto* sync_point = SyncPoint::get_instance();
+ Defer clear_sync_point {[sync_point]() {
+ sync_point->disable_processing();
+ sync_point->clear_call_back(
+
"PythonServerManager::_ensure_pool_initialized:generation_started");
+ sync_point->clear_call_back(
+
"PythonServerManager::_ensure_pool_initialized:init_worker_finished");
+ }};
+ sync_point->set_call_back(
+ "PythonServerManager::_ensure_pool_initialized:generation_started",
+ [generation_completion, version](auto&& args) {
+ const auto* started_version = try_any_cast<const
PythonVersion*>(args.at(0));
+ if (*started_version != version) {
+ return;
+ }
+ std::lock_guard lock(generation_completion->mutex);
+ generation_completion->generation =
try_any_cast<std::atomic<int>*>(args.at(1));
+ });
+ sync_point->set_call_back(
+
"PythonServerManager::_ensure_pool_initialized:init_worker_finished",
+ [generation_completion, version](auto&& args) {
+ const auto* finished_version = try_any_cast<const
PythonVersion*>(args.at(0));
+ if (*finished_version != version) {
+ return;
+ }
+ auto* finished_generation =
try_any_cast<std::atomic<int>*>(args.at(1));
+ {
+ std::lock_guard lock(generation_completion->mutex);
+ if (finished_generation !=
generation_completion->generation) {
+ return;
+ }
+ generation_completion->worker_finished = true;
+ }
+ generation_completion->cv.notify_all();
+ });
+ sync_point->enable_processing();
+
+ auto failed_result = mgr._ensure_pool_initialized(version);
+ ASSERT_FALSE(failed_result.has_value());
+
+ // Wait for both parts of the failed generation to finish. Otherwise an
old detached worker can
+ // execute the repaired file and publish its process into the next
generation's pool.
+ {
+ std::unique_lock lock(generation_completion->mutex);
+ ASSERT_TRUE(generation_completion->cv.wait_for(lock,
std::chrono::seconds(5), [&]() {
+ return generation_completion->worker_finished;
+ }));
+ }
+ ASSERT_TRUE(mgr.wait_for_process_pool_initialization_finished_for_test(
+ version, std::chrono::seconds(5)));
- auto start = std::chrono::steady_clock::now();
- auto result = mgr._ensure_pool_initialized(invalid_version);
- auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
- std::chrono::steady_clock::now() - start);
+ // Repair the runtime in place and retry the same version key. Production
can hit this when an
+ // environment or server entry is fixed after a transient initialization
failure.
+ ASSERT_EQ(create_fake_python_with_socket_creation("3.9.16"), python_path);
+ auto recovered_result = mgr._ensure_pool_initialized(version);
+ ASSERT_TRUE(recovered_result.has_value()) <<
recovered_result.error().to_string();
- EXPECT_FALSE(result.has_value());
- EXPECT_LT(elapsed.count(), 500);
+ ProcessPtr process;
+ ASSERT_TRUE(mgr._get_process(version, recovered_result.value(),
&process).ok());
+ ASSERT_NE(process, nullptr);
+ EXPECT_TRUE(process->is_alive());
+
+ mgr.shutdown();
}
TEST_F(PythonServerTest,
EnsurePoolInitializedAfterShutdownReturnsServiceUnavailable) {
@@ -434,13 +453,6 @@ TEST_F(PythonServerTest,
EnsurePoolInitializedAfterShutdownReturnsServiceUnavail
// PythonServerManager::shutdown() - shutdown test
// ============================================================================
-TEST_F(PythonServerTest, ShutdownEmptyManagerDoesNotCrash) {
- PythonServerManager mgr;
-
- // Verify: calling shutdown on empty manager does not crash
- EXPECT_NO_THROW(mgr.shutdown());
-}
-
TEST_F(PythonServerTest, ShutdownCalledMultipleTimesDoesNotCrash) {
PythonServerManager mgr;
@@ -543,32 +555,32 @@ TEST_F(PythonServerTest,
GetClientWithInvalidVersionFails) {
EXPECT_EQ(client, nullptr);
}
-// ============================================================================
-// configuration test
-// ============================================================================
-
-TEST_F(PythonServerTest, MaxPythonProcessNumConfigIsAccessible) {
- // Verify configuration value is accessible and within a valid range
- int max_num = config::max_python_process_num;
- EXPECT_GE(max_num, 0); // 0 means use number of CPU cores
-}
-
// ============================================================================
// destructor test
// ============================================================================
TEST_F(PythonServerTest, DestructorCleansUpResources) {
- // Create and destroy manager to ensure no memory leaks or crashes
+ setup_doris_home();
+ std::string python_path =
create_fake_python_with_socket_creation("3.9.16");
+ config::max_python_process_num = 1;
+
+ ProcessPtr process;
+ std::string socket_path;
{
PythonServerManager mgr;
- // Try some operations (they fail but should not affect destructor)
- PythonVersion invalid_version("3.9.16", "/bad", "/bad");
- ProcessPtr process;
- Status status = mgr.fork(invalid_version, &process);
- EXPECT_FALSE(status.ok());
+ PythonVersion version("3.9.16", test_dir_, python_path);
+ auto pool_result = mgr._ensure_pool_initialized(version);
+ ASSERT_TRUE(pool_result.has_value()) <<
pool_result.error().to_string();
+ ASSERT_TRUE(mgr._get_process(version, pool_result.value(),
&process).ok());
+ ASSERT_NE(process, nullptr);
+ ASSERT_TRUE(process->is_alive());
+ socket_path = process->get_socket_file_path();
+ ASSERT_TRUE(fs::exists(socket_path));
}
- // If we reach here without crashing, destructor works properly
- SUCCEED();
+
+ EXPECT_TRUE(process->is_shutdown());
+ EXPECT_FALSE(process->is_alive());
+ EXPECT_FALSE(fs::exists(socket_path));
}
// ============================================================================
@@ -619,52 +631,6 @@ TEST_F(PythonServerTest, EnsurePoolInitializedSuccess) {
mgr.shutdown();
}
-TEST_F(PythonServerTest,
EnsurePoolInitializedLogsProgressWhileWaitingForSlowProcess) {
- setup_doris_home();
- std::string python_path =
-
create_fake_python_with_delay_and_socket_creation("python3.delayed", "3.9.16",
50);
-
- config::max_python_process_num = 1;
-
- PythonServerManager mgr;
- PythonVersion version("3.9.16", test_dir_, python_path);
-
- auto result = mgr._ensure_pool_initialized(version);
-
- EXPECT_TRUE(result.has_value()) << result.error().to_string();
-
- mgr.shutdown();
-}
-
-TEST_F(PythonServerTest,
EnsurePoolInitializedRetriesAfterInitFailureWithBoundedWait) {
- setup_doris_home();
- std::string python_path =
- create_fake_python_without_socket_creation("python3.no_socket",
"3.9.16");
-
- config::max_python_process_num = 1;
-
- PythonServerManager mgr;
- PythonVersion version("3.9.16", test_dir_, python_path);
-
- auto start = std::chrono::steady_clock::now();
- auto result = mgr._ensure_pool_initialized(version);
- auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
- std::chrono::steady_clock::now() - start);
-
- EXPECT_FALSE(result.has_value());
- EXPECT_LT(elapsed.count(), 2000);
-
- start = std::chrono::steady_clock::now();
- auto retry_result = mgr._ensure_pool_initialized(version);
- elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
- std::chrono::steady_clock::now() - start);
-
- EXPECT_FALSE(retry_result.has_value());
- EXPECT_LT(elapsed.count(), 2000);
-
- mgr.shutdown();
-}
-
TEST_F(PythonServerTest,
EnsurePoolInitializedSucceedsWithOneStuckWorkerAndOneUsableWorker) {
setup_doris_home();
std::string python_path =
@@ -675,23 +641,19 @@ TEST_F(PythonServerTest,
EnsurePoolInitializedSucceedsWithOneStuckWorkerAndOneUs
PythonServerManager mgr;
PythonVersion version("3.9.16", test_dir_, python_path);
- auto start = std::chrono::steady_clock::now();
auto result = mgr._ensure_pool_initialized(version);
- auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
- std::chrono::steady_clock::now() - start);
ASSERT_TRUE(result.has_value()) << result.error().to_string();
- EXPECT_LT(elapsed.count(), 2000);
- EXPECT_TRUE(mgr.process_pool_is_initializing_for_test(version));
ProcessPtr process;
EXPECT_TRUE(mgr._get_process(version, result.value(), &process).ok());
ASSERT_NE(process, nullptr);
EXPECT_TRUE(process->is_alive());
- for (int i = 0; i < 20 &&
!mgr.process_pool_is_initialized_for_test(version); ++i) {
- std::this_thread::sleep_for(std::chrono::milliseconds(100));
- }
+ // Wait on the same condition variable used by the production coordinator
instead of polling
+ // or asserting on a racy intermediate state.
+ EXPECT_TRUE(mgr.wait_for_process_pool_initialized_for_test(version,
+
std::chrono::milliseconds(2000)));
EXPECT_TRUE(mgr.process_pool_is_initialized_for_test(version));
mgr.shutdown();
@@ -708,11 +670,20 @@ TEST_F(PythonServerTest, EnsurePoolInitializedIdempotent)
{
// First initialization
auto result1 = mgr._ensure_pool_initialized(version);
- EXPECT_TRUE(result1.has_value()) << result1.error().to_string();
+ ASSERT_TRUE(result1.has_value()) << result1.error().to_string();
+ auto first_snapshot = mgr.process_pool_snapshot_for_test(version);
+ ASSERT_EQ(first_snapshot.size(), 1);
+ ASSERT_NE(first_snapshot[0], nullptr);
+ pid_t first_pid = first_snapshot[0]->get_child_pid();
- // Second initialization should return immediately (version already
initialized)
+ // Re-initialization must reuse both the versioned pool and its live
process.
auto result2 = mgr._ensure_pool_initialized(version);
- EXPECT_TRUE(result2.has_value()) << result2.error().to_string();
+ ASSERT_TRUE(result2.has_value()) << result2.error().to_string();
+ EXPECT_EQ(result2.value(), result1.value());
+ auto second_snapshot = mgr.process_pool_snapshot_for_test(version);
+ ASSERT_EQ(second_snapshot.size(), 1);
+ EXPECT_EQ(second_snapshot[0], first_snapshot[0]);
+ EXPECT_EQ(second_snapshot[0]->get_child_pid(), first_pid);
mgr.shutdown();
}
@@ -811,30 +782,22 @@ TEST_F(PythonServerTest,
GetProcessSkipsDeadProcessWhenAliveProcessExists) {
mgr.shutdown();
}
-TEST_F(PythonServerTest, GetProcessLoadBalancing) {
- setup_doris_home();
- std::string python_path =
create_fake_python_with_socket_creation("3.9.16");
-
- // Create a pool with 2 processes
- config::max_python_process_num = 2;
-
+TEST_F(PythonServerTest, GetProcessSelectsLeastSharedProcess) {
PythonServerManager mgr;
- PythonVersion version("3.9.16", test_dir_, python_path);
+ PythonVersion version("3.9.16", test_dir_, test_dir_ + "/unused_python");
+ mgr.set_process_pool_for_test(version, {create_sleep_process(),
create_sleep_process()});
auto init_result = mgr._ensure_pool_initialized(version);
EXPECT_TRUE(init_result.has_value()) << init_result.error().to_string();
- // Get multiple processes to verify load balancing
- ProcessPtr p1, p2, p3, p4;
- EXPECT_TRUE(mgr._get_process(version, init_result.value(), &p1).ok());
- EXPECT_TRUE(mgr._get_process(version, init_result.value(), &p2).ok());
- EXPECT_TRUE(mgr._get_process(version, init_result.value(), &p3).ok());
- EXPECT_TRUE(mgr._get_process(version, init_result.value(), &p4).ok());
-
- // With 2 processes, load balancing distributes requests across different
processes
- // p1 and p2 may be same or different processes
- EXPECT_NE(p1, nullptr);
- EXPECT_NE(p2, nullptr);
+ // Holding p1 increases its shared ownership count, so the next client
must select the other
+ // live process. An implementation that always returns pool[0] fails this
assertion.
+ ProcessPtr p1, p2;
+ ASSERT_TRUE(mgr._get_process(version, init_result.value(), &p1).ok());
+ ASSERT_NE(p1, nullptr);
+ ASSERT_TRUE(mgr._get_process(version, init_result.value(), &p2).ok());
+ ASSERT_NE(p2, nullptr);
+ EXPECT_NE(p1->get_child_pid(), p2->get_child_pid());
mgr.shutdown();
}
@@ -924,33 +887,82 @@ TEST_F(PythonServerTest,
EnsurePoolInitializedForDifferentVersionsDoesNotShareVe
config::max_python_process_num = 1;
- std::string python39_path =
- create_fake_python_with_delay_and_socket_creation("python3.9",
"3.9.16", 50);
- std::string python310_path =
- create_fake_python_with_delay_and_socket_creation("python3.10",
"3.10.0", 50);
+ std::string python39_path =
create_fake_python_with_socket_creation("3.9.16");
+ std::string python310_path = test_dir_ + "/bin/python3.10";
+ ASSERT_TRUE(fs::copy_file(python39_path, python310_path));
+ fs::permissions(python310_path, fs::perms::owner_all);
PythonServerManager mgr;
PythonVersion version39("3.9.16", test_dir_, python39_path);
PythonVersion version310("3.10.0", test_dir_, python310_path);
- auto start = std::chrono::steady_clock::now();
+ struct ForkBarrier {
+ std::mutex mutex;
+ std::condition_variable cv;
+ int entries = 0;
+ bool released = false;
+ };
+ auto fork_barrier = std::make_shared<ForkBarrier>();
+ auto* sync_point = SyncPoint::get_instance();
+ Defer clear_sync_point {[fork_barrier, sync_point]() {
+ {
+ std::lock_guard lock(fork_barrier->mutex);
+ fork_barrier->released = true;
+ }
+ fork_barrier->cv.notify_all();
+ sync_point->disable_processing();
+
sync_point->clear_call_back("PythonServerManager::fork:before_process_start");
+ sync_point->clear_call_back(
+
"PythonServerManager::_ensure_pool_initialized:process_pool_init_timeout");
+ }};
+ // Keep callers inside initialization longer than the fork barrier. A
manager-wide lock must
+ // therefore fail the barrier instead of serializing through the short
BE_TEST pool timeout.
+ sync_point->set_call_back(
+
"PythonServerManager::_ensure_pool_initialized:process_pool_init_timeout",
+ [](auto&& args) {
+ auto* timeout =
try_any_cast<std::chrono::milliseconds*>(args.front());
+ *timeout = std::chrono::seconds(10);
+ });
+ sync_point->set_call_back(
+ "PythonServerManager::fork:before_process_start",
+ [fork_barrier, version39, version310](auto&& args) {
+ if (args.empty()) {
+ return;
+ }
+ const auto* entering_version = try_any_cast<const
PythonVersion*>(args.front());
+ if (*entering_version != version39 && *entering_version !=
version310) {
+ return;
+ }
+ std::unique_lock lock(fork_barrier->mutex);
+ ++fork_barrier->entries;
+ fork_barrier->cv.notify_all();
+ fork_barrier->cv.wait(lock, [&]() { return
fork_barrier->released; });
+ });
+ sync_point->enable_processing();
+
auto future39 = std::async(std::launch::async,
[&]() { return
mgr._ensure_pool_initialized(version39); });
auto future310 = std::async(std::launch::async,
[&]() { return
mgr._ensure_pool_initialized(version310); });
- auto result39 = future39.get();
- auto result310 = future310.get();
- auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
- std::chrono::steady_clock::now() - start);
+ bool both_forks_entered = false;
+ {
+ std::unique_lock lock(fork_barrier->mutex);
+ both_forks_entered = fork_barrier->cv.wait_for(
+ lock, std::chrono::seconds(5), [&]() { return
fork_barrier->entries >= 2; });
+ fork_barrier->released = true;
+ }
+ fork_barrier->cv.notify_all();
- EXPECT_TRUE(result39.has_value()) << result39.error().to_string();
- EXPECT_TRUE(result310.has_value()) << result310.error().to_string();
- // Keep the assertion loose for ASAN/CI scheduling while still catching
full init-timeout
- // serialization between versions.
- EXPECT_LT(elapsed.count(), 2000);
+ // The assertion below is the concurrency evidence. Pool completion also
has a separate short
+ // BE_TEST timeout, so its status must not turn host scheduling after this
barrier into noise.
+ static_cast<void>(future39.get());
+ static_cast<void>(future310.get());
mgr.shutdown();
+
+ EXPECT_TRUE(both_forks_entered)
+ << "Both version pools must enter fork before either process
starts";
}
// ============================================================================
diff --git a/be/test/udf/python/python_udf_meta_test.cpp
b/be/test/udf/python/python_udf_meta_test.cpp
index 43085430510..66067b37f35 100644
--- a/be/test/udf/python/python_udf_meta_test.cpp
+++ b/be/test/udf/python/python_udf_meta_test.cpp
@@ -17,6 +17,9 @@
#include "udf/python/python_udf_meta.h"
+#include <arrow/io/memory.h>
+#include <arrow/ipc/reader.h>
+#include <arrow/util/base64.h>
#include <gtest/gtest.h>
#include <rapidjson/document.h>
@@ -29,6 +32,14 @@
namespace doris {
+static arrow::Result<std::shared_ptr<arrow::Schema>> decode_arrow_schema(
+ const std::string& encoded_schema) {
+ auto buffer =
arrow::Buffer::FromString(arrow::util::base64_decode(encoded_schema));
+ arrow::io::BufferReader reader(buffer);
+ arrow::ipc::DictionaryMemo dictionary_memo;
+ return arrow::ipc::ReadSchema(&reader, &dictionary_memo);
+}
+
class PythonUDFMetaTest : public ::testing::Test {
protected:
void SetUp() override {
@@ -46,23 +57,6 @@ protected:
DataTypePtr nullable_double_;
};
-// ============================================================================
-// PythonUDFMeta construction tests
-// ============================================================================
-
-TEST_F(PythonUDFMetaTest, DefaultConstruction) {
- PythonUDFMeta meta;
- EXPECT_TRUE(meta.name.empty());
- EXPECT_TRUE(meta.symbol.empty());
- EXPECT_TRUE(meta.location.empty());
- EXPECT_TRUE(meta.checksum.empty());
- EXPECT_TRUE(meta.runtime_version.empty());
- EXPECT_TRUE(meta.inline_code.empty());
- EXPECT_FALSE(meta.always_nullable);
- EXPECT_TRUE(meta.input_types.empty());
- EXPECT_EQ(meta.return_type, nullptr);
-}
-
// ============================================================================
// PythonUDFMeta check() tests
// ============================================================================
@@ -254,77 +248,6 @@ TEST_F(PythonUDFMetaTest, CheckWhitespaceOnlyName) {
EXPECT_FALSE(status.ok());
}
-// ============================================================================
-// PythonUDFMeta to_string() tests
-// ============================================================================
-
-TEST_F(PythonUDFMetaTest, ToStringContainsAllFields) {
- PythonUDFMeta meta;
- meta.name = "my_udf";
- meta.symbol = "udf_func";
- meta.location = "/path/to/udf.py";
- meta.runtime_version = "3.10.5";
- meta.always_nullable = true;
- meta.inline_code = "def udf_func(x): return x";
- meta.input_types = {nullable_int32_, nullable_string_};
- meta.return_type = nullable_double_;
-
- std::string str = meta.to_string();
- EXPECT_TRUE(str.find("my_udf") != std::string::npos);
- EXPECT_TRUE(str.find("udf_func") != std::string::npos);
- EXPECT_TRUE(str.find("/path/to/udf.py") != std::string::npos);
- EXPECT_TRUE(str.find("3.10.5") != std::string::npos);
-}
-
-TEST_F(PythonUDFMetaTest, ToStringMultipleInputTypes) {
- PythonUDFMeta meta;
- meta.name = "multi_arg_udf";
- meta.symbol = "func";
- meta.runtime_version = "3.9.16";
- meta.input_types = {nullable_int32_, nullable_string_, nullable_double_};
- meta.return_type = nullable_int32_;
-
- std::string str = meta.to_string();
- // Should contain input_types section
- EXPECT_TRUE(str.find("input_types") != std::string::npos);
-}
-
-// ============================================================================
-// PythonUDFMeta equality tests
-// ============================================================================
-
-TEST_F(PythonUDFMetaTest, EqualityById) {
- PythonUDFMeta meta1;
- meta1.id = 100;
- meta1.name = "udf1";
-
- PythonUDFMeta meta2;
- meta2.id = 100;
- meta2.name = "different_name";
-
- PythonUDFMeta meta3;
- meta3.id = 200;
- meta3.name = "udf1";
-
- EXPECT_EQ(meta1, meta2); // Same ID
- EXPECT_FALSE(meta1 == meta3); // Different ID
-}
-
-TEST_F(PythonUDFMetaTest, HashById) {
- PythonUDFMeta meta1;
- meta1.id = 100;
-
- PythonUDFMeta meta2;
- meta2.id = 100;
-
- PythonUDFMeta meta3;
- meta3.id = 200;
-
- std::hash<PythonUDFMeta> hasher;
- EXPECT_EQ(hasher(meta1), hasher(meta2));
- EXPECT_NE(hasher(meta1), hasher(meta3));
-}
-
// ============================================================================
// PythonUDFMeta serialize_to_json() tests
// ============================================================================
@@ -379,6 +302,22 @@ TEST_F(PythonUDFMetaTest, SerializeToJsonBasic) {
EXPECT_TRUE(doc.HasMember("inline_code"));
EXPECT_TRUE(doc.HasMember("input_types"));
EXPECT_TRUE(doc.HasMember("return_type"));
+
+ EXPECT_EQ(arrow::util::base64_decode(doc["inline_code"].GetString()),
meta.inline_code);
+
+ auto input_schema_result =
decode_arrow_schema(doc["input_types"].GetString());
+ ASSERT_TRUE(input_schema_result.ok()) <<
input_schema_result.status().ToString();
+ auto input_schema = *input_schema_result;
+ ASSERT_EQ(input_schema->num_fields(), 1);
+ EXPECT_TRUE(input_schema->field(0)->type()->Equals(arrow::int32()));
+ EXPECT_TRUE(input_schema->field(0)->nullable());
+
+ auto return_schema_result =
decode_arrow_schema(doc["return_type"].GetString());
+ ASSERT_TRUE(return_schema_result.ok()) <<
return_schema_result.status().ToString();
+ auto return_schema = *return_schema_result;
+ ASSERT_EQ(return_schema->num_fields(), 1);
+ EXPECT_TRUE(return_schema->field(0)->type()->Equals(arrow::int32()));
+ EXPECT_TRUE(return_schema->field(0)->nullable());
}
TEST_F(PythonUDFMetaTest, SerializeToJsonDifferentClientTypes) {
@@ -430,7 +369,16 @@ TEST_F(PythonUDFMetaTest,
SerializeToJsonMultipleInputTypes) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
EXPECT_FALSE(doc.HasParseError());
- EXPECT_TRUE(doc.HasMember("input_types"));
+ auto input_schema_result =
decode_arrow_schema(doc["input_types"].GetString());
+ ASSERT_TRUE(input_schema_result.ok()) <<
input_schema_result.status().ToString();
+ auto input_schema = *input_schema_result;
+ ASSERT_EQ(input_schema->num_fields(), 3);
+ EXPECT_TRUE(input_schema->field(0)->type()->Equals(arrow::int32()));
+ EXPECT_TRUE(input_schema->field(1)->type()->Equals(arrow::utf8()));
+ EXPECT_TRUE(input_schema->field(2)->type()->Equals(arrow::float64()));
+ EXPECT_TRUE(input_schema->field(0)->nullable());
+ EXPECT_TRUE(input_schema->field(1)->nullable());
+ EXPECT_TRUE(input_schema->field(2)->nullable());
}
TEST_F(PythonUDFMetaTest, SerializeToJsonEmptyInputTypesForUdf) {
@@ -450,8 +398,9 @@ TEST_F(PythonUDFMetaTest,
SerializeToJsonEmptyInputTypesForUdf) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
EXPECT_FALSE(doc.HasParseError());
- EXPECT_TRUE(doc.HasMember("input_types"));
- EXPECT_FALSE(std::string(doc["input_types"].GetString()).empty());
+ auto input_schema_result =
decode_arrow_schema(doc["input_types"].GetString());
+ ASSERT_TRUE(input_schema_result.ok()) <<
input_schema_result.status().ToString();
+ EXPECT_EQ((*input_schema_result)->num_fields(), 0);
}
// ============================================================================
@@ -469,6 +418,10 @@ TEST_F(PythonUDFMetaTest, ConvertTypesToSchemaBasic) {
EXPECT_EQ(schema->num_fields(), 2);
EXPECT_EQ(schema->field(0)->name(), "arg0");
EXPECT_EQ(schema->field(1)->name(), "arg1");
+ EXPECT_TRUE(schema->field(0)->type()->Equals(arrow::int32()));
+ EXPECT_TRUE(schema->field(1)->type()->Equals(arrow::utf8()));
+ EXPECT_TRUE(schema->field(0)->nullable());
+ EXPECT_TRUE(schema->field(1)->nullable());
}
TEST_F(PythonUDFMetaTest, ConvertTypesToSchemaSingleType) {
@@ -480,6 +433,9 @@ TEST_F(PythonUDFMetaTest, ConvertTypesToSchemaSingleType) {
EXPECT_TRUE(status.ok()) << status.to_string();
EXPECT_NE(schema, nullptr);
EXPECT_EQ(schema->num_fields(), 1);
+ EXPECT_EQ(schema->field(0)->name(), "arg0");
+ EXPECT_TRUE(schema->field(0)->type()->Equals(arrow::float64()));
+ EXPECT_TRUE(schema->field(0)->nullable());
}
TEST_F(PythonUDFMetaTest, ConvertTypesToSchemaEmpty) {
@@ -506,6 +462,12 @@ TEST_F(PythonUDFMetaTest, SerializeArrowSchema) {
EXPECT_TRUE(status.ok()) << status.to_string();
EXPECT_NE(buffer, nullptr);
EXPECT_GT(buffer->size(), 0);
+
+ arrow::io::BufferReader reader(buffer);
+ arrow::ipc::DictionaryMemo dictionary_memo;
+ auto decoded_schema_result = arrow::ipc::ReadSchema(&reader,
&dictionary_memo);
+ ASSERT_TRUE(decoded_schema_result.ok()) <<
decoded_schema_result.status().ToString();
+ EXPECT_TRUE((*decoded_schema_result)->Equals(*schema));
}
} // namespace doris
diff --git a/be/test/udf/python/python_udf_runtime_test.cpp
b/be/test/udf/python/python_udf_runtime_test.cpp
index 0f570c06810..4b07b0b7fb8 100644
--- a/be/test/udf/python/python_udf_runtime_test.cpp
+++ b/be/test/udf/python/python_udf_runtime_test.cpp
@@ -25,8 +25,11 @@
#include <boost/process.hpp>
#include <filesystem>
+#include <optional>
#include <string>
+#include "util/defer_op.h"
+
namespace doris {
namespace fs = std::filesystem;
@@ -69,41 +72,18 @@ protected:
// Helper function tests
// ============================================================================
-TEST_F(PythonUDFRuntimeTest, GetBaseUnixSocketPath) {
- std::string path = get_base_unix_socket_path();
- EXPECT_TRUE(path.find("grpc+unix://") != std::string::npos);
- EXPECT_TRUE(path.find("/tmp/doris_python_udf") != std::string::npos);
-}
-
-TEST_F(PythonUDFRuntimeTest, GetUnixSocketPath) {
- pid_t test_pid = 12345;
- std::string path = get_unix_socket_path(test_pid);
- EXPECT_TRUE(path.find("grpc+unix://") != std::string::npos);
- EXPECT_TRUE(path.find("12345") != std::string::npos);
- EXPECT_TRUE(path.find(".sock") != std::string::npos);
-}
-
-TEST_F(PythonUDFRuntimeTest, GetUnixSocketFilePathFormat) {
- pid_t test_pid = 99999;
- std::string path = get_unix_socket_file_path(test_pid);
- EXPECT_TRUE(path.find("/tmp/doris_python_udf") != std::string::npos);
- EXPECT_TRUE(path.find("99999") != std::string::npos);
- EXPECT_TRUE(path.find(".sock") != std::string::npos);
- // Should NOT have grpc+unix:// prefix
- EXPECT_TRUE(path.find("grpc+unix://") == std::string::npos);
-}
-
-TEST_F(PythonUDFRuntimeTest, GetUnixSocketPathDifferentPids) {
- std::string path1 = get_unix_socket_path(1000);
- std::string path2 = get_unix_socket_path(2000);
- EXPECT_NE(path1, path2);
- EXPECT_TRUE(path1.find("1000") != std::string::npos);
- EXPECT_TRUE(path2.find("2000") != std::string::npos);
+TEST_F(PythonUDFRuntimeTest, UnixSocketPathsMatchFlightContract) {
+ EXPECT_EQ(get_base_unix_socket_path(),
"grpc+unix:///tmp/doris_python_udf");
+ EXPECT_EQ(get_unix_socket_path(12345),
"grpc+unix:///tmp/doris_python_udf_12345.sock");
+ EXPECT_EQ(get_unix_socket_file_path(12345),
"/tmp/doris_python_udf_12345.sock");
}
TEST_F(PythonUDFRuntimeTest, GetFightServerPath) {
// Save original DORIS_HOME
- const char* original_doris_home = std::getenv("DORIS_HOME");
+ std::optional<std::string> original_doris_home;
+ if (const char* doris_home = std::getenv("DORIS_HOME")) {
+ original_doris_home = doris_home;
+ }
// Set test DORIS_HOME
setenv("DORIS_HOME", "/test/doris/home", 1);
@@ -113,26 +93,12 @@ TEST_F(PythonUDFRuntimeTest, GetFightServerPath) {
// Restore original DORIS_HOME
if (original_doris_home) {
- setenv("DORIS_HOME", original_doris_home, 1);
+ setenv("DORIS_HOME", original_doris_home->c_str(), 1);
} else {
unsetenv("DORIS_HOME");
}
}
-// ============================================================================
-// PythonUDFProcess tests (without actually spawning a process)
-// ============================================================================
-
-// Note: Most PythonUDFProcess tests require actually spawning Python
processes,
-// which is environment-dependent. Here we test what we can without real
processes.
-
-TEST_F(PythonUDFRuntimeTest, ProcessPtrIsSharedPtr) {
- // Verify ProcessPtr is a shared_ptr
- ProcessPtr ptr = nullptr;
- EXPECT_EQ(ptr, nullptr);
- EXPECT_FALSE(ptr);
-}
-
TEST_F(PythonUDFRuntimeTest, WaitChildExitReturnsExitedForExitedChild) {
bp::ipstream output;
bp::child child("/bin/bash", "-c", "exit 7", bp::std_out > output);
@@ -203,52 +169,6 @@ TEST_F(PythonUDFRuntimeTest,
BackgroundReaperReapsQueuedChild) {
EXPECT_EQ(result, PythonUDFProcess::ChildExitWaitResult::ALREADY_REAPED);
}
-// Test socket file path generation for various PIDs
-TEST_F(PythonUDFRuntimeTest, SocketPathGenerationEdgeCases) {
- // Minimum PID
- std::string path1 = get_unix_socket_file_path(1);
- EXPECT_TRUE(path1.find("_1.sock") != std::string::npos);
-
- // Large PID
- std::string path2 = get_unix_socket_file_path(999999);
- EXPECT_TRUE(path2.find("_999999.sock") != std::string::npos);
-}
-
-TEST_F(PythonUDFRuntimeTest, SocketPathConsistency) {
- pid_t pid = 54321;
- // Multiple calls should return same result
- std::string path1 = get_unix_socket_path(pid);
- std::string path2 = get_unix_socket_path(pid);
- EXPECT_EQ(path1, path2);
-
- std::string file_path1 = get_unix_socket_file_path(pid);
- std::string file_path2 = get_unix_socket_file_path(pid);
- EXPECT_EQ(file_path1, file_path2);
-}
-
-TEST_F(PythonUDFRuntimeTest, UnixSocketPathRelationship) {
- pid_t pid = 12345;
- std::string socket_path = get_unix_socket_path(pid);
- std::string file_path = get_unix_socket_file_path(pid);
-
- // Socket path should contain the prefix + file path structure
- EXPECT_TRUE(socket_path.find(UNIX_SOCKET_PREFIX) == 0);
-
- // File path should not contain the prefix
- EXPECT_TRUE(file_path.find(UNIX_SOCKET_PREFIX) == std::string::npos);
-}
-
-// ============================================================================
-// Socket path security tests
-// ============================================================================
-
-TEST_F(PythonUDFRuntimeTest, SocketPathInTmpDirectory) {
- // All socket files should be in /tmp to avoid path length issues
- pid_t pid = 12345;
- std::string file_path = get_unix_socket_file_path(pid);
- EXPECT_TRUE(file_path.find("/tmp/") == 0);
-}
-
TEST_F(PythonUDFRuntimeTest, SocketPathLength) {
// Unix socket paths have a maximum length (usually 107 chars)
// Verify generated paths are within reasonable limits
@@ -259,18 +179,6 @@ TEST_F(PythonUDFRuntimeTest, SocketPathLength) {
EXPECT_LT(path.length(), 100);
}
-// ============================================================================
-// Flight server path tests
-// ============================================================================
-
-TEST_F(PythonUDFRuntimeTest, FlightServerPathTemplate) {
- // Verify template includes necessary components
- std::string tmpl = FLIGHT_SERVER_PATH_TEMPLATE;
- EXPECT_TRUE(tmpl.find("{}") != std::string::npos); // Has placeholder
- EXPECT_TRUE(tmpl.find("plugins") != std::string::npos);
- EXPECT_TRUE(tmpl.find("python_udf") != std::string::npos);
-}
-
// ============================================================================
// PythonUDFProcess shutdown() tests
// ============================================================================
@@ -375,126 +283,43 @@ TEST_F(PythonUDFRuntimeTest,
RemoveUnixSocketExistingFile) {
EXPECT_FALSE(fs::exists(socket_path));
}
-TEST_F(PythonUDFRuntimeTest, RemoveUnixSocketNonExistent) {
+TEST_F(PythonUDFRuntimeTest, ShutdownPreservesUnexpectedSocketDirectory) {
bp::ipstream output;
bp::child child("/bin/sleep", "60", bp::std_out > output);
+ ASSERT_TRUE(child.valid());
+ ASSERT_TRUE(child.running());
- pid_t child_pid = child.id();
PythonUDFProcess process(std::move(child), std::move(output));
+ const std::string socket_path = process.get_socket_file_path();
+ fs::remove_all(socket_path);
+ ASSERT_TRUE(fs::create_directory(socket_path));
+ Defer cleanup {[&]() { fs::remove_all(socket_path); }};
- // Don't create socket file - it doesn't exist
- std::string socket_path = get_unix_socket_file_path(child_pid);
- ASSERT_FALSE(fs::exists(socket_path));
-
- // Shutdown should not crash even if socket doesn't exist (ENOENT case)
+ // A directory at the derived socket path makes unlink() fail with EISDIR.
Shutdown must still
+ // finish, and it must not recursively delete an unexpected filesystem
object.
process.shutdown();
- EXPECT_TRUE(process.is_shutdown());
-}
-
-TEST_F(PythonUDFRuntimeTest, RemoveUnixSocketIsDirectory) {
- bp::ipstream output;
- bp::child child("/bin/sleep", "60", bp::std_out > output);
-
- pid_t child_pid = child.id();
- // Create a directory at the socket path location (instead of a file)
- // This will cause unlink() to fail with EISDIR
- std::string socket_path = get_unix_socket_file_path(child_pid);
- fs::create_directories(socket_path);
- ASSERT_TRUE(fs::is_directory(socket_path));
-
- PythonUDFProcess process(std::move(child), std::move(output));
-
- // Shutdown should handle EISDIR error gracefully (logs warning but
doesn't crash)
- process.shutdown();
EXPECT_TRUE(process.is_shutdown());
-
- // Cleanup - remove the directory we created
- fs::remove_all(socket_path);
-}
-
-// ============================================================================
-// PythonUDFProcess to_string() tests
-// ============================================================================
-
-TEST_F(PythonUDFRuntimeTest, ToStringFormat) {
- bp::ipstream output;
- bp::child child("/bin/sleep", "60", bp::std_out > output);
-
- pid_t child_pid = child.id();
- PythonUDFProcess process(std::move(child), std::move(output));
-
- std::string str = process.to_string();
-
- // Verify to_string contains expected fields
- EXPECT_TRUE(str.find("PythonUDFProcess") != std::string::npos);
- EXPECT_TRUE(str.find("child_pid") != std::string::npos);
- EXPECT_TRUE(str.find(std::to_string(child_pid)) != std::string::npos);
- EXPECT_TRUE(str.find("uri") != std::string::npos);
- EXPECT_TRUE(str.find("unix_socket_file_path") != std::string::npos);
- EXPECT_TRUE(str.find("is_shutdown") != std::string::npos);
-
- process.shutdown();
+ EXPECT_TRUE(fs::is_directory(socket_path));
}
// ============================================================================
// PythonUDFProcess getter tests
// ============================================================================
-TEST_F(PythonUDFRuntimeTest, GetUri) {
- bp::ipstream output;
- bp::child child("/bin/sleep", "60", bp::std_out > output);
-
- pid_t child_pid = child.id();
- PythonUDFProcess process(std::move(child), std::move(output));
-
- std::string uri = process.get_uri();
-
- // URI should contain the grpc+unix prefix and pid
- EXPECT_TRUE(uri.find("grpc+unix://") != std::string::npos);
- EXPECT_TRUE(uri.find(std::to_string(child_pid)) != std::string::npos);
- EXPECT_TRUE(uri.find(".sock") != std::string::npos);
-
- process.shutdown();
-}
-
-TEST_F(PythonUDFRuntimeTest, GetSocketFilePath) {
+TEST_F(PythonUDFRuntimeTest, ProcessUsesPidDerivedSocketPaths) {
bp::ipstream output;
bp::child child("/bin/sleep", "60", bp::std_out > output);
pid_t child_pid = child.id();
PythonUDFProcess process(std::move(child), std::move(output));
- const std::string& path = process.get_socket_file_path();
-
- // File path should NOT have grpc+unix prefix
- EXPECT_TRUE(path.find("grpc+unix://") == std::string::npos);
- EXPECT_TRUE(path.find(std::to_string(child_pid)) != std::string::npos);
- EXPECT_TRUE(path.find(".sock") != std::string::npos);
- EXPECT_TRUE(path.find("/tmp/") == 0);
+ EXPECT_EQ(process.get_uri(), get_unix_socket_path(child_pid));
+ EXPECT_EQ(process.get_socket_file_path(),
get_unix_socket_file_path(child_pid));
process.shutdown();
}
-// ============================================================================
-// PythonUDFProcess equality tests
-// ============================================================================
-
-TEST_F(PythonUDFRuntimeTest, ProcessEquality) {
- bp::ipstream output1, output2;
- bp::child child1("/bin/sleep", "60", bp::std_out > output1);
- bp::child child2("/bin/sleep", "60", bp::std_out > output2);
-
- PythonUDFProcess process1(std::move(child1), std::move(output1));
- PythonUDFProcess process2(std::move(child2), std::move(output2));
-
- // Different processes should not be equal (different PIDs)
- EXPECT_NE(process1, process2);
-
- process1.shutdown();
- process2.shutdown();
-}
-
// ============================================================================
// PythonUDFProcess destructor tests
// ============================================================================
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]