Skip to content

Commit 82dd2aa

Browse files
committed
feat: per-worker proxy for table function execute callback
Wire the execute path to per-worker proxy threads on DuckDB >= 1.5.0. A local_init callback registered via duckdb_table_function_set_local_init runs once per worker thread, creates a proxy (allocating its Ruby thread under the GVL through the global executor, since local_init runs on a non-Ruby thread), and stores it as thread-local init data. The execute callback retrieves that proxy and dispatches through it via rbduckdb_function_executor_dispatch_via_proxy, so callbacks from different workers run concurrently instead of serializing on the single global executor. DuckDB frees each proxy through rbduckdb_worker_proxy_destroy. bind and init stay on the global executor (not on the hot path). On DuckDB < 1.5.0 the local_init hook is absent and the execute callback keeps using the global executor unchanged. Verified: with SET threads=4 plus cardinality/max_threads hints, a GVL-releasing callback reaches max_concurrent=4 (vs 2 on the global executor) for a ~2x speedup; results are identical. The added test asserts correctness of the local_init -> proxy -> destroy lifecycle under multi-threaded execution (throughput is checked manually to avoid CI flakiness).
1 parent 49b1244 commit 82dd2aa

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

ext/duckdb/table_function.c

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ static VALUE rbduckdb_table_function_set_init(VALUE self);
2121
static void table_function_init_callback(duckdb_init_info info);
2222
static VALUE rbduckdb_table_function_set_execute(VALUE self);
2323
static void table_function_execute_callback(duckdb_function_info info, duckdb_data_chunk output);
24+
#ifdef HAVE_DUCKDB_H_GE_V1_5_0
25+
/* Thread detection (declared in function_executor.c); used to skip the proxy on Ruby threads. */
26+
extern int ruby_native_thread_p(void);
27+
static void table_function_local_init_callback(duckdb_init_info info);
28+
#endif
2429

2530
static const rb_data_type_t table_function_data_type = {
2631
"DuckDB/TableFunction",
@@ -358,6 +363,10 @@ static VALUE rbduckdb_table_function_set_execute(VALUE self) {
358363

359364
ctx->execute_proc = rb_block_proc();
360365
duckdb_table_function_set_function(ctx->table_function, table_function_execute_callback);
366+
#ifdef HAVE_DUCKDB_H_GE_V1_5_0
367+
/* Per-worker proxy threads for the execute path (DuckDB >= 1.5.0). */
368+
duckdb_table_function_set_local_init(ctx->table_function, table_function_local_init_callback);
369+
#endif
361370

362371
rbduckdb_function_executor_ensure_started();
363372

@@ -405,6 +414,7 @@ static void execute_execute_callback_protected(void *user_data) {
405414
static void table_function_execute_callback(duckdb_function_info info, duckdb_data_chunk output) {
406415
rubyDuckDBTableFunction *ctx;
407416
struct execute_dispatch_arg darg;
417+
struct worker_proxy *proxy = NULL;
408418

409419
ctx = (rubyDuckDBTableFunction *)duckdb_function_get_extra_info(info);
410420
if (!ctx || ctx->execute_proc == Qnil) return;
@@ -413,9 +423,48 @@ static void table_function_execute_callback(duckdb_function_info info, duckdb_da
413423
darg.info = info;
414424
darg.output = output;
415425

416-
rbduckdb_function_executor_dispatch(execute_execute_callback_protected, &darg);
426+
#ifdef HAVE_DUCKDB_H_GE_V1_5_0
427+
/* On DuckDB >= 1.5.0 each worker thread carries its own proxy (see local_init). */
428+
proxy = (struct worker_proxy *)duckdb_function_get_local_init_data(info);
429+
#endif
430+
rbduckdb_function_executor_dispatch_via_proxy(execute_execute_callback_protected, &darg, proxy);
417431
}
418432

433+
#ifdef HAVE_DUCKDB_H_GE_V1_5_0
434+
/*
435+
* Per-worker init for the execute path (DuckDB >= 1.5.0).
436+
*
437+
* DuckDB calls this once on each worker thread that will run the execute
438+
* callback. We create a per-worker proxy (allocating its Ruby thread under the
439+
* GVL via the global executor, since this runs on a non-Ruby thread) and store
440+
* it as thread-local init data. The execute callback then dispatches through it
441+
* instead of the shared global executor, so workers run callbacks concurrently.
442+
* DuckDB invokes rbduckdb_worker_proxy_destroy when the local state is freed.
443+
*/
444+
struct table_proxy_create_arg {
445+
struct worker_proxy *proxy;
446+
};
447+
448+
static void table_create_proxy_callback(void *user_data) {
449+
struct table_proxy_create_arg *arg = (struct table_proxy_create_arg *)user_data;
450+
arg->proxy = rbduckdb_worker_proxy_create();
451+
}
452+
453+
static void table_function_local_init_callback(duckdb_init_info info) {
454+
struct table_proxy_create_arg arg;
455+
456+
/* A Ruby calling thread runs the callback inline (Case 1/2); no proxy needed. */
457+
if (ruby_native_thread_p()) return;
458+
459+
arg.proxy = NULL;
460+
rbduckdb_function_executor_dispatch(table_create_proxy_callback, &arg);
461+
462+
if (arg.proxy != NULL) {
463+
duckdb_init_set_init_data(info, arg.proxy, rbduckdb_worker_proxy_destroy);
464+
}
465+
}
466+
#endif
467+
419468
rubyDuckDBTableFunction *get_struct_table_function(VALUE self) {
420469
rubyDuckDBTableFunction *ctx;
421470
TypedData_Get_Struct(self, rubyDuckDBTableFunction, &table_function_data_type, ctx);

test/duckdb_test/table_function_test.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,66 @@ def test_symbol_columns
199199
db.close
200200
end
201201

202+
# Per-worker proxy (GH-1136): exercises the local_init -> proxy -> destroy
203+
# lifecycle under real multi-threaded execution (SET threads=4). We assert
204+
# correctness rather than a concurrency count: the proxy path must neither
205+
# deadlock nor corrupt results when DuckDB distributes the scan across
206+
# workers. Parallel *throughput* is verified separately by
207+
# letters/issue-1136/table_function_parallel_check.rb (kept out of CI to
208+
# avoid scheduler-dependent flakiness). Requires DuckDB >= 1.5.0
209+
# (duckdb_table_function_set_local_init).
210+
def test_execute_runs_correctly_under_multiple_threads
211+
if ::DuckDBTest.duckdb_library_version < Gem::Version.new('1.5.0')
212+
skip 'per-worker proxy requires DuckDB >= 1.5.0'
213+
end
214+
215+
chunks = 64
216+
rows_per_chunk = 100
217+
remaining = chunks
218+
mutex = Mutex.new
219+
220+
db = DuckDB::Database.open
221+
conn = db.connect
222+
conn.execute('SET threads=4')
223+
224+
tf = DuckDB::TableFunction.new
225+
tf.name = 'parallel_emitter'
226+
tf.bind do |bind_info|
227+
bind_info.add_result_column('v', DuckDB::LogicalType::BIGINT)
228+
# Tell the planner there is real work so it distributes across workers.
229+
bind_info.set_cardinality(chunks * rows_per_chunk, false)
230+
end
231+
tf.init do |init_info|
232+
# Without this DuckDB assigns a single worker and the proxy never fires.
233+
init_info.max_threads = 4
234+
end
235+
tf.execute do |_info, output|
236+
has_work = mutex.synchronize do
237+
next false if remaining.zero?
238+
239+
remaining -= 1
240+
true
241+
end
242+
243+
unless has_work
244+
output.size = 0
245+
next
246+
end
247+
248+
rows_per_chunk.times { |i| output.set_value(0, i, 1) }
249+
output.size = rows_per_chunk
250+
sleep 0.001 # release the GVL so workers can overlap
251+
end
252+
253+
conn.register_table_function(tf)
254+
result = conn.query('SELECT COUNT(*), SUM(v) FROM parallel_emitter()').each.to_a
255+
256+
assert_equal [chunks * rows_per_chunk, chunks * rows_per_chunk], result.first
257+
258+
conn.disconnect
259+
db.close
260+
end
261+
202262
private
203263

204264
def setup_incomplete_function

0 commit comments

Comments
 (0)