Skip to content

Commit 121a987

Browse files
committed
[Fix](udf) Key UDF class cache by function ID and enable cleanup in cloud mode (#67046)
Problem Summary: UDF cache cleanup has two problems: 1. Cloud mode does not register a worker for `CLEAN_UDF_CACHE`, so `DROP FUNCTION` cannot clean the cached UDF classloader. 2. UDF caches are cleaned by function signature. If a function is dropped and recreated with the same signature, a delayed cleanup task may delete the new cache, or the recreated function may reuse the stale cache. The FE removes the function metadata first and then submits `CleanUDFCacheTask` asynchronously. It does not wait for the BE cache cleanup result. In addition, the cleanup task does not report a completion result back to the FE. Therefore, even if the task cannot be submitted or the JNI cache cleanup fails, `DROP FUNCTION` still returns success to the client. ```sql CREATE FUNCTION test_udf(INT) RETURNS INT ...; -- implementation V1 SELECT test_udf(1); -- cache V1 DROP FUNCTION test_udf(INT); -- cache cleanup fails or is delayed CREATE FUNCTION test_udf(INT) RETURNS INT ...; -- implementation V2 SELECT test_udf(1); ```` Before this PR, the last query could reuse V1's cached classloader because V1 and V2 had the same signature. A delayed cleanup task for V1 could also remove V2's cache. - Register the CLEAN_UDF_CACHE worker in cloud mode. - Use the function ID as the key for Java UDF cache lookup, insertion, and cleanup. = Fall back to signature-based cleanup when no valid function ID is provided for compatibility with older FEs. A recreated function receives a new function ID. Therefore, even if the previous function's cache is not successfully removed, the recreated function does not reuse it and can load and execute the correct implementation. A delayed cleanup task also removes only the old function's cache without affecting the recreated function.
1 parent b87c177 commit 121a987

13 files changed

Lines changed: 290 additions & 80 deletions

File tree

be/src/agent/agent_server.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ void AgentServer::cloud_start_workers(CloudStorageEngine& engine, ExecEnv* exec_
250250
return make_cloud_committed_rs_visible_callback(engine, task);
251251
});
252252

253+
_workers[TTaskType::CLEAN_UDF_CACHE] = std::make_unique<TaskWorkerPool>(
254+
"CLEAN_UDF_CACHE", 1, [](auto&& task) { return clean_udf_cache_callback(task); });
255+
253256
_report_workers.push_back(std::make_unique<ReportWorker>(
254257
"REPORT_TASK", _cluster_info, config::report_task_interval_seconds,
255258
[&cluster_info = _cluster_info] { report_task_callback(cluster_info); }));

be/src/agent/task_worker_pool.cpp

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,17 +2577,30 @@ void clean_trash_callback(StorageEngine& engine, const TAgentTaskRequest& req) {
25772577

25782578
void clean_udf_cache_callback(const TAgentTaskRequest& req) {
25792579
const auto& clean_req = req.clean_udf_cache_req;
2580-
2581-
if (doris::config::enable_java_support) {
2582-
static_cast<void>(Jni::Util::clean_udf_class_load_cache(clean_req.function_signature));
2580+
if (clean_req.__isset.function_id && clean_req.function_id <= 0) {
2581+
LOG(WARNING) << "skip clean udf cache request with invalid function_id="
2582+
<< clean_req.function_id
2583+
<< ", function_signature=" << clean_req.function_signature;
2584+
return;
25832585
}
2586+
// Requests from old FEs do not set function_id and must keep signature-based cleanup.
2587+
const bool drop_by_function_id = clean_req.__isset.function_id;
25842588

2585-
if (clean_req.__isset.function_id && clean_req.function_id > 0) {
2589+
if (doris::config::enable_java_support) {
2590+
WARN_IF_ERROR(
2591+
Jni::Util::clean_udf_class_load_cache(
2592+
clean_req.function_signature,
2593+
drop_by_function_id ? clean_req.function_id : 0),
2594+
fmt::format("failed to clean Java UDF cache, function_signature={}, function_id={}",
2595+
clean_req.function_signature, clean_req.function_id));
2596+
}
2597+
if (drop_by_function_id) {
25862598
UserFunctionCache::instance()->drop_function_cache(clean_req.function_id);
25872599
PythonServerManager::instance().clear_udaf_state_cache(clean_req.function_id);
25882600
}
25892601

2590-
LOG(INFO) << "clean udf cache finish: function_signature=" << clean_req.function_signature;
2602+
LOG(INFO) << "clean udf cache callback finish: function_signature="
2603+
<< clean_req.function_signature << ", function_id=" << clean_req.function_id;
25912604
}
25922605

25932606
void report_index_policy_callback(const ClusterInfo* cluster_info) {

be/src/util/jni-util.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Status Util::_init_jni_scanner_loader() {
316316
jni_scanner_loader_cls.get_method(env, "loadAllScannerJars", "()V", &load_jni_scanner));
317317

318318
RETURN_IF_ERROR(jni_scanner_loader_cls.get_method(
319-
env, "cleanUdfClassLoader", "(Ljava/lang/String;)V", &_clean_udf_cache_method_id));
319+
env, "cleanUdfClassLoader", "(Ljava/lang/String;J)V", &_clean_udf_cache_method_id));
320320

321321
RETURN_IF_ERROR(jni_scanner_loader_cls.new_object(env, jni_scanner_loader_constructor)
322322
.call(&jni_scanner_loader_obj_));
@@ -325,7 +325,8 @@ Status Util::_init_jni_scanner_loader() {
325325
return Status::OK();
326326
}
327327

328-
Status Util::clean_udf_class_load_cache(const std::string& function_signature) {
328+
Status Util::clean_udf_class_load_cache(const std::string& function_signature,
329+
int64_t function_id) {
329330
JNIEnv* env = nullptr;
330331
RETURN_IF_ERROR(Jni::Env::Get(&env));
331332

@@ -335,6 +336,7 @@ Status Util::clean_udf_class_load_cache(const std::string& function_signature) {
335336

336337
RETURN_IF_ERROR(jni_scanner_loader_obj_.call_void_method(env, _clean_udf_cache_method_id)
337338
.with_arg(function_signature_jstr)
339+
.with_arg((jlong)function_id)
338340
.call());
339341

340342
return Status::OK();

be/src/util/jni-util.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,8 @@ class Util {
11561156
return Status::OK();
11571157
}
11581158

1159-
static Status clean_udf_class_load_cache(const std::string& function_signature);
1159+
static Status clean_udf_class_load_cache(const std::string& function_signature,
1160+
int64_t function_id);
11601161

11611162
static Status Init();
11621163

be/test/agent/task_worker_pool_test.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
#include <chrono>
2626
#include <thread>
2727

28+
#include "agent/agent_server.h"
29+
#include "cloud/cloud_storage_engine.h"
2830
#include "runtime/cluster_info.h"
31+
#include "runtime/exec_env.h"
2932
#include "storage/options.h"
3033
#include "storage/storage_engine.h"
3134

@@ -181,4 +184,19 @@ TEST(TaskWorkerPoolTest, ReportWorkerPool) {
181184
EXPECT_EQ(count.load(), 3);
182185
}
183186

187+
TEST(AgentServerTest, CloudRegistersCleanUdfCacheWorker) {
188+
auto* exec_env = ExecEnv::GetInstance();
189+
auto engine = std::make_unique<CloudStorageEngine>(EngineOptions {});
190+
auto* cloud_engine = engine.get();
191+
exec_env->set_storage_engine(std::move(engine));
192+
Defer defer {[exec_env] { exec_env->set_storage_engine(nullptr); }};
193+
194+
ClusterInfo cluster_info;
195+
AgentServer agent_server(exec_env, &cluster_info);
196+
197+
agent_server.cloud_start_workers(*cloud_engine, exec_env);
198+
199+
EXPECT_TRUE(agent_server._workers.contains(TTaskType::CLEAN_UDF_CACHE));
200+
}
201+
184202
} // namespace doris

fe/be-java-extensions/java-common/src/main/java/org/apache/doris/common/classloader/ScannerLoader.java

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,14 @@ public class ScannerLoader {
9797
// 2) rebuilding a fresh URLClassLoader on every eviction produced multiple coexisting
9898
// ClassLoaders for the same UDF, which broke lazy class resolution and reflective
9999
// lookups inside user UDF code.
100+
// Cache by function id so a recreated function with the same signature does not reuse
101+
// the previous function's class loader.
100102
// NOTE: a cache miss in BaseExecutor.getClassCache() is NOT only reachable after
101-
// cleanUdfClassLoader() — concurrent first-time loads of the same signature can also
103+
// cleanUdfClassLoader() — concurrent first-time loads of the same function can also
102104
// both observe a miss. cacheClassLoader() must therefore insert atomically via
103105
// putIfAbsent and must never close a cache that was already published to the map,
104106
// because another executor may already be holding it.
105-
private static final Map<String, UdfClassCache> udfLoadedClasses = new ConcurrentHashMap<>();
107+
private static final Map<Long, UdfClassCacheEntry> udfLoadedClasses = new ConcurrentHashMap<>();
106108
private static final String CLASS_SUFFIX = ".class";
107109
private static final String LOAD_PACKAGE = "org.apache.doris";
108110

@@ -126,61 +128,93 @@ public void loadAllScannerJars() {
126128
LOG.info("Finished loading scanner JARs");
127129
}
128130

129-
public static UdfClassCache getUdfClassLoader(String functionSignature) {
130-
return udfLoadedClasses.get(functionSignature);
131+
private static class UdfClassCacheEntry {
132+
private final String functionSignature;
133+
private final UdfClassCache classCache;
134+
135+
UdfClassCacheEntry(String functionSignature, UdfClassCache classCache) {
136+
this.functionSignature = functionSignature;
137+
this.classCache = classCache;
138+
}
139+
}
140+
141+
public static UdfClassCache getUdfClassLoader(long functionId) {
142+
UdfClassCacheEntry entry = udfLoadedClasses.get(functionId);
143+
return entry == null ? null : entry.classCache;
131144
}
132145

133146
/**
134-
* Cache the UDF class metadata for the given function signature.
147+
* Cache the UDF class metadata for the given catalog function id.
135148
*
136-
* <p>Insertion is atomic via {@link Map#putIfAbsent}: if another executor thread has
137-
* already published a cache entry for {@code functionSignature}, the {@code classCache}
149+
* <p>Insertion is atomic via {@link Map#putIfAbsent}: if another executor
150+
* thread has already published a cache entry for {@code functionId}, the {@code classCache}
138151
* argument is treated as a redundant build and closed here (it has not yet been handed
139152
* to any executor, so closing its URLClassLoader is safe). The already-published entry
140153
* is returned to the caller so the current executor can switch to it.</p>
141154
*
142155
* <p>The {@code expirationTime} parameter is kept for backward compatibility with the
143156
* existing call sites and DDL property {@code expiration_time}, but is no longer used:
144157
* cached entries are not evicted by time. Removal happens only via
145-
* {@link #cleanUdfClassLoader(String)} on DROP FUNCTION.</p>
158+
* {@link #cleanUdfClassLoader(String, long)} on DROP FUNCTION.</p>
146159
*
147160
* @return the {@link UdfClassCache} actually held in the map after this call —
148161
* either {@code classCache} (we won the race) or the pre-existing entry
149162
* (another thread won; {@code classCache} has been closed and must not be used).
150163
*/
151-
public static UdfClassCache cacheClassLoader(String functionSignature, UdfClassCache classCache,
152-
long expirationTime) {
153-
LOG.info("Cache UDF for: " + functionSignature);
154-
UdfClassCache existing = udfLoadedClasses.putIfAbsent(functionSignature, classCache);
164+
public static UdfClassCache cacheClassLoader(String functionSignature, long functionId,
165+
UdfClassCache classCache, long expirationTime) {
166+
LOG.info("Cache UDF for function signature: {}, function id: {}", functionSignature, functionId);
167+
UdfClassCacheEntry newEntry = new UdfClassCacheEntry(functionSignature, classCache);
168+
UdfClassCacheEntry existing = udfLoadedClasses.putIfAbsent(functionId, newEntry);
155169
if (existing == null) {
156170
return classCache;
157171
}
158172
// Lost the race against a concurrent first-time load. The cache we just built has
159173
// never been exposed to any executor, so closing its URLClassLoader here cannot
160174
// affect anyone. Do NOT touch `existing` — another executor may already be using it.
161175
try {
162-
classCache.close();
176+
newEntry.classCache.close();
163177
} catch (Exception e) {
164-
LOG.warn("Failed to close redundant UdfClassCache for " + functionSignature, e);
178+
LOG.warn("Failed to close UdfClassCache for function signature: {}, function id: {}",
179+
newEntry.functionSignature, functionId, e);
165180
}
166-
return existing;
181+
return existing.classCache;
167182
}
168183

169-
public void cleanUdfClassLoader(String functionSignature) {
170-
LOG.info("cleanUdfClassLoader for: " + functionSignature);
171-
UdfClassCache removed = udfLoadedClasses.remove(functionSignature);
172-
if (removed != null) {
173-
// Immediately close the URLClassLoader. NOTE: any in-flight query still holding a
174-
// reference to this cache (e.g. via JNIContext.executor) will fail with
175-
// NoClassDefFoundError on lazy class resolution after this point. This is the
176-
// accepted semantic of DROP FUNCTION: the function is gone, queries against it
177-
// are expected to fail.
178-
try {
179-
removed.close();
180-
} catch (Exception e) {
181-
LOG.warn("Failed to close UdfClassCache for " + functionSignature, e);
184+
public void cleanUdfClassLoader(String functionSignature, long functionId) {
185+
LOG.info("cleanUdfClassLoader for function signature: {}, function id: {}",
186+
functionSignature, functionId);
187+
if (functionId > 0) {
188+
UdfClassCacheEntry removed = udfLoadedClasses.remove(functionId);
189+
if (removed != null) {
190+
// Immediately close the URLClassLoader. NOTE: any in-flight query still holding a
191+
// reference to this cache (e.g. via JNIContext.executor) will fail with
192+
// NoClassDefFoundError on lazy class resolution after this point. This is the
193+
// accepted semantic of DROP FUNCTION: the function is gone, queries against it
194+
// are expected to fail.
195+
try {
196+
removed.classCache.close();
197+
} catch (Exception e) {
198+
LOG.warn("Failed to close UdfClassCache for function signature: {}, function id: {}",
199+
removed.functionSignature, functionId, e);
200+
}
182201
}
202+
return;
183203
}
204+
205+
// Old FEs do not set function_id in cleanup requests, so remove every cache with
206+
// the requested signature.
207+
udfLoadedClasses.forEach((cachedFunctionId, entry) -> {
208+
if (entry.functionSignature.equals(functionSignature)
209+
&& udfLoadedClasses.remove(cachedFunctionId, entry)) {
210+
try {
211+
entry.classCache.close();
212+
} catch (Exception e) {
213+
LOG.warn("Failed to close UdfClassCache for function signature: {}, function id: {}",
214+
entry.functionSignature, cachedFunctionId, e);
215+
}
216+
}
217+
});
184218
}
185219

186220
/**
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.doris.common.classloader;
19+
20+
import org.apache.doris.common.jni.utils.UdfClassCache;
21+
22+
import org.junit.Assert;
23+
import org.junit.Test;
24+
25+
public class ScannerLoaderTest {
26+
@Test
27+
public void testCleanCacheByFunctionId() {
28+
long oldFunctionId = 10001;
29+
long newFunctionId = 10002;
30+
String functionSignature = "recreated_function(INT)";
31+
UdfClassCache oldCache = new UdfClassCache();
32+
UdfClassCache newCache = new UdfClassCache();
33+
ScannerLoader loader = new ScannerLoader();
34+
35+
try {
36+
ScannerLoader.cacheClassLoader(functionSignature, oldFunctionId, oldCache, 0);
37+
ScannerLoader.cacheClassLoader(functionSignature, newFunctionId, newCache, 0);
38+
39+
loader.cleanUdfClassLoader(functionSignature, oldFunctionId);
40+
41+
Assert.assertNull(ScannerLoader.getUdfClassLoader(oldFunctionId));
42+
Assert.assertSame(newCache, ScannerLoader.getUdfClassLoader(newFunctionId));
43+
} finally {
44+
loader.cleanUdfClassLoader(functionSignature, oldFunctionId);
45+
loader.cleanUdfClassLoader(functionSignature, newFunctionId);
46+
}
47+
}
48+
49+
@Test
50+
public void testCleanAllCachesByFunctionSignatureWithoutFunctionId() {
51+
long firstFunctionId = 10003;
52+
long secondFunctionId = 10004;
53+
long otherFunctionId = 10005;
54+
String functionSignature = "legacy_function(INT)";
55+
String otherFunctionSignature = "other_function(INT)";
56+
UdfClassCache firstCache = new UdfClassCache();
57+
UdfClassCache secondCache = new UdfClassCache();
58+
UdfClassCache otherCache = new UdfClassCache();
59+
ScannerLoader loader = new ScannerLoader();
60+
61+
try {
62+
ScannerLoader.cacheClassLoader(functionSignature, firstFunctionId, firstCache, 0);
63+
ScannerLoader.cacheClassLoader(functionSignature, secondFunctionId, secondCache, 0);
64+
ScannerLoader.cacheClassLoader(otherFunctionSignature, otherFunctionId, otherCache, 0);
65+
66+
loader.cleanUdfClassLoader(functionSignature, 0);
67+
68+
Assert.assertNull(ScannerLoader.getUdfClassLoader(firstFunctionId));
69+
Assert.assertNull(ScannerLoader.getUdfClassLoader(secondFunctionId));
70+
Assert.assertSame(otherCache, ScannerLoader.getUdfClassLoader(otherFunctionId));
71+
} finally {
72+
loader.cleanUdfClassLoader(functionSignature, firstFunctionId);
73+
loader.cleanUdfClassLoader(functionSignature, secondFunctionId);
74+
loader.cleanUdfClassLoader(otherFunctionSignature, otherFunctionId);
75+
}
76+
}
77+
}

fe/be-java-extensions/java-udf/src/main/java/org/apache/doris/udf/BaseExecutor.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ protected void init(TJavaUdfExecutorCtorParams request, String jarPath,
109109
if (request.getFn().isSetExpirationTime()) {
110110
expirationTime = request.getFn().getExpirationTime();
111111
}
112-
objCache = getClassCache(jarPath, request.getFn().getSignature(), expirationTime,
113-
funcRetType, parameterTypes);
112+
objCache = getClassCache(jarPath, request.getFn().getSignature(), request.getFn().getId(),
113+
expirationTime, funcRetType, parameterTypes);
114114
Constructor<?> ctor = objCache.udfClass.getConstructor();
115115
udf = ctor.newInstance();
116116
} catch (MalformedURLException e) {
@@ -131,13 +131,13 @@ protected void init(TJavaUdfExecutorCtorParams request, String jarPath,
131131
}
132132

133133

134-
public UdfClassCache getClassCache(String jarPath, String signature, long expirationTime,
135-
Type funcRetType, Type... parameterTypes)
134+
public UdfClassCache getClassCache(String jarPath, String functionSignature, long functionId,
135+
long expirationTime, Type funcRetType, Type... parameterTypes)
136136
throws MalformedURLException, FileNotFoundException, ClassNotFoundException, InternalException,
137137
UdfRuntimeException {
138138
UdfClassCache cache = null;
139139
if (isStaticLoad) {
140-
cache = ScannerLoader.getUdfClassLoader(signature);
140+
cache = ScannerLoader.getUdfClassLoader(functionId);
141141
if (cache != null) {
142142
// Reuse the cached classLoader to ensure dependent classes can be loaded.
143143
// NOTE: cache.classLoader may be null when the UDF was originally loaded via
@@ -166,7 +166,8 @@ public UdfClassCache getClassCache(String jarPath, String signature, long expira
166166
cache.classLoader = classLoader;
167167
checkAndCacheUdfClass(cache, funcRetType, parameterTypes);
168168
if (isStaticLoad) {
169-
UdfClassCache effective = ScannerLoader.cacheClassLoader(signature, cache, expirationTime);
169+
UdfClassCache effective = ScannerLoader.cacheClassLoader(
170+
functionSignature, functionId, cache, expirationTime);
170171
if (effective != cache) {
171172
// Another thread won the publish race. Our locally-built cache (and its
172173
// URLClassLoader) was already closed inside cacheClassLoader(); switch to

0 commit comments

Comments
 (0)