fix(core): resolve ScanContextBuilder executor lazily in Finish() by wangyong9999 · Pull Request #282 · apache/paimon-cpp · GitHub
Skip to content

fix(core): resolve ScanContextBuilder executor lazily in Finish() - #282

Open
wangyong9999 wants to merge 4 commits into
apache:mainfrom
wangyong9999:fix/scan-context-builder-lazy-executor
Open

fix(core): resolve ScanContextBuilder executor lazily in Finish()#282
wangyong9999 wants to merge 4 commits into
apache:mainfrom
wangyong9999:fix/scan-context-builder-lazy-executor

Conversation

@wangyong9999

@wangyong9999 wangyong9999 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: none

ScanContextBuilder::Impl eagerly calls CreateDefaultExecutor() twice per builder
lifetime: once in the member initializer and once more in Reset(), which Finish()
invokes right after building the context. DefaultExecutor spawns
DEFAULT_EXECUTOR_THREAD_COUNT (4) worker threads in its constructor and joins them on
destruction, so every ScanContextBuilder costs eight thread creations even when the
caller supplies its own executor through WithExecutor().

Point-lookup style serving code builds one ScanContextBuilder per key. Measured with
strace -f -c -e trace=clone on such a path, each lookup performed about nine clone
calls and eight of them came from this builder.

This change keeps every context on a DefaultExecutor of its own (no process wide
executor is introduced) and removes the thread churn in two places:

  • DefaultExecutor starts its worker threads on the first Add() instead of in the
    constructor. An executor that never receives a task never spawns a thread, and
    shutting down or destroying such an executor only cleans up state. Executors that do
    receive tasks behave as before.
  • ScanContextBuilder::Impl::executor_ defaults to null and Reset() only clears it.
    Finish() resolves the executor: the one set through WithExecutor() if any,
    otherwise a fresh CreateDefaultExecutor() owned by the returned context. This
    matches what ReadContextBuilder already does.

With both changes a lookup that supplies its own executor creates no thread at all,
and one that relies on the default scan executor creates the four workers once, when
the scan actually submits work.

Tests

  • DefaultExecutorTest.TestWorkersStartOnFirstTask (new): CreateDefaultExecutor(4)
    creates no thread; four appear after the first tasks run; all are gone after the
    executor is destroyed (thread count read from /proc/self/task, Linux only).
  • DefaultExecutorTest.TestShutdownWithoutTasks (new): ShutdownNow() and
    destruction of an executor that never ran a task.
  • ScanContextTest.TestDefaultExecutorIsCreatedPerContext (new): contexts built
    without WithExecutor() get distinct non-null executors, none of them the global one, and an executor set on the
    builder only applies to the Finish() that follows it.
  • All 12 DefaultExecutorTest.* cases (10 existing + 2 new) and the 4
    ScanContextTest.* cases pass locally in a Release build (paimon-common-test,
    paimon-core-test).

API and Format

No public API or storage format change. DefaultExecutor worker threads now start on
the first submitted task rather than at construction; this is only observable through
thread counts.

Documentation

No.

Generative AI tooling

Generated-by: Claude Code (Claude Fable 5.1)

ScanContextBuilder::Impl created a DefaultExecutor in its member initializer and again in Reset(), spawning and joining eight worker threads per builder even when the caller supplied an executor. Keep the executor unset until Finish(), which falls back to GetGlobalDefaultExecutor() when nothing was set, so ScanContext still carries a non-null executor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not use GlobalDefaultExecutor. It creates a number of threads proportional to the number of CPU cores and may also introduce potential locking issues.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One concrete failure mode is FileStoreScan::ReadFileEntries(): it submits manifest work to the context executor and then blocks in CollectAll(). If all global workers are already running scans, every worker can wait for child work queued to the same pool and deadlock. Please keep the fallback lazy but private (CreateDefaultExecutor() in Finish()), while leaving Reset() null; that removes the eager thread creation without changing executor isolation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed: a CPU-proportional shared pool is the wrong tool for a serving path, and FileStoreScan::ReadFileEntries() blocking in CollectAll() on that same pool could deadlock once every worker is inside a scan. Pushed 3475256, which drops GetGlobalDefaultExecutor() entirely:

  • DefaultExecutor now starts its workers on the first Add() instead of in the constructor. An executor that never receives a task never spawns a thread, and shutting down or destroying such an executor only cleans up state; executors that do run tasks behave exactly as before. workers_ stays guarded by the existing state_->mutex, no new lock.
  • ScanContextBuilder::Finish() falls back to a fresh CreateDefaultExecutor() owned by the returned context, the same as ReadContextBuilder does. Nothing is shared across contexts.

Net effect on the per-lookup path: constructing or resetting a builder creates no thread (previously 8), a context that supplies its own executor creates none at all, and the four default workers only appear once the scan actually submits work. Covered by DefaultExecutorTest.TestWorkersStartOnFirstTask / TestShutdownWithoutTasks and ScanContextTest.TestDefaultExecutorIsCreatedPerContext; the PR description is updated to match.

Comment thread src/paimon/core/operation/scan_context_test.cpp Outdated
Review feedback on apache#282: do not fall back to the process wide
GetGlobalDefaultExecutor() in ScanContextBuilder::Finish(). Each context
keeps a DefaultExecutor of its own again; the thread churn is removed by
having DefaultExecutor spawn its workers on the first Add() instead of in
the constructor, so an executor that never receives a task never creates
a thread.
@lxy-9602
lxy-9602 requested a review from lucasfang September 4, 2026 08:57
Comment thread src/paimon/common/executor/default_executor_test.cpp Outdated
wangyong.alen added 2 commits September 4, 2026 05:27
paimon-common-test runs every suite in one process, so a worker joined by
an earlier test can still be listed in /proc/self/task when the baseline
is read. Take the baseline once two consecutive reads agree and compare
with <= / >= instead of exact counts.
Pin the isolation explicitly: a context built without WithExecutor()
must not fall back to GetGlobalDefaultExecutor().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants