This is an automated email from the ASF dual-hosted git repository.

RexXiong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new 61499a0c9 [CELEBORN-2319] Standalone LifecycleManager && rust sdk
61499a0c9 is described below

commit 61499a0c9ac40786c0e846009f0ea77e1b1d42ed
Author: 夷羿 <[email protected]>
AuthorDate: Thu Jun 11 21:55:41 2026 +0800

    [CELEBORN-2319] Standalone LifecycleManager && rust sdk
    
    ### What changes were proposed in this pull request?
    
    This PR introduces two major features to support **non-JVM (C++/Rust) 
clients** using Apache Celeborn for shuffle:
    
    **1. Standalone LifecycleManager Daemon (Scala/JVM)**
    
    - Added `LifecycleManagerDaemon` — a standalone JVM process that hosts a 
`LifecycleManager` independently from any compute engine (Spark/Flink) Driver. 
It installs a shutdown hook (with a watchdog that force-halts if graceful stop 
exceeds the timeout) and blocks until SIGINT/SIGTERM.
    - Added `LifecycleManagerDaemonArguments` for CLI argument parsing 
(`--app-id`, `--master-endpoints`, `--port`/`-p`, `--host`, 
`--properties-file`, `-h`/`--help`). Parsing is a pure function that throws 
`ArgumentParseException` (carrying an exit code) so every branch is 
unit-testable; `parseOrExit` wraps it for the process entry point.
    - Added `sbin/start-lifecycle-manager.sh` launch script with classpath 
assembly, environment loading, required-argument validation, automatic 
free-port selection, and **RPC-port polling** to confirm the daemon is actually 
bound before reporting success.
    - Added a new **`lifecycle-manager` Maven/sbt module** that depends on 
`celeborn-service`, `celeborn-client` and `celeborn-common`. Registered the 
module in both the root `pom.xml` and `project/CelebornBuild.scala` 
(`projectDefinitions`), and wired it into `build/make-distribution.sh` for both 
the Maven and sbt build paths.
    - **Security note**: the standalone LM runs without authentication; it logs 
a warning on startup and the code documents that operators must bind it to a 
trusted network only.
    
    **2. Rust SDK via C++ FFI (`rust/` directory)**
    
    - `celeborn-client-sys`: Low-level FFI crate bridging Rust ↔ C++ via a 
**plain C ABI** (no `cxx`). The C++ side exposes `celeborn_ffi_*` functions 
(`create_client`, `setup_lifecycle_manager`, `shutdown`, `push_data`, 
`mapper_end`, partition reader open/read/close, etc.) returning status codes 
plus heap-allocated error strings.
      - `build.rs`: links the single aggregated shared library 
`libceleborn_client.{so,dylib}` (which whole-archives all internal static libs 
and hides non-`celeborn` symbols), so downstream Rust never sees folly / 
protobuf / glog / abseil.
    - `celeborn-client`: Safe, ergonomic Rust wrapper providing `ShuffleClient` 
with:
      - Input validation extracted into a pure `validate_connect_args` (app_id 
non-empty, port > 0, codec ∈ {NONE, LZ4, ZSTD}) — testable without a live 
cluster.
      - Documented `Send + Sync` rationale (the C++ `ShuffleClientImpl` 
synchronizes internally), enabling `Arc<ShuffleClient>` sharing for concurrent 
`&self` push/read.
      - `Drop`-safe shutdown that nulls the handle to avoid a double 
`celeborn_ffi_shutdown`. The native handle is **intentionally leaked** after 
shutdown to dodge a folly `EventBase` teardown race; this implies a 
**per-process-client** usage model, which is documented prominently on the type.
    - Two example programs (`data_sum_writer.rs`, `data_sum_reader.rs`) 
mirroring the existing C++ `DataSumWithWriterClient` / 
`DataSumWithReaderClient` test programs. The writer seeds its RNG **per mapper 
thread** so each thread emits a distinct byte stream, genuinely exercising the 
concurrent push path.
    
    **3. C++ build portability (`cpp/CMakeLists.txt`)**
    
    - Discover the Homebrew prefix via `brew --prefix` instead of hard-coding 
`/opt/homebrew` (so Intel macOS under `/usr/local` works too).
    - Discover Abseil via `find_package(absl CONFIG)`, falling back to a 
toolchain-derived GNU multiarch dir (covers `aarch64-linux-gnu`, which the 
README advertises); a missing Abseil is now a `FATAL_ERROR` rather than a 
silent warning.
    - Guard the x86-only `-msse4.2` flag by architecture so aarch64 builds 
compile.
    
    ### Why are the changes needed?
    
    Currently, `LifecycleManager` can only run **embedded inside a JVM-based 
compute engine Driver** (e.g., Spark Driver). This makes it impossible for 
non-JVM applications (Daft engine, etc.) to use Celeborn as their shuffle 
service, because:
    
    1. The C++ client requires a running `LifecycleManager` to coordinate 
shuffle metadata (register shuffles, allocate slots, manage partition 
locations) with Celeborn Masters and Workers.
    2. Without a standalone `LifecycleManager`, non-JVM applications have no 
way to bootstrap this coordination layer.
    
    By decoupling the `LifecycleManager` into a **standalone daemon process**, 
any client — regardless of language runtime — can connect to it via RPC. The 
Rust SDK then leverages this architecture to provide first-class Rust support 
by bridging to the existing, battle-tested C++ client implementation via FFI.
    
    ### Does this PR resolve a correctness bug?
    
    No
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    - **New component**: Users can now start a standalone `LifecycleManager` 
daemon via `sbin/start-lifecycle-manager.sh --app-id <id> --master-endpoints 
<eps> [--port <port>] [--host <host>]`.
    - **New SDK**: Rust applications can now use the `celeborn-client` crate to 
perform shuffle read/write operations against a Celeborn cluster.
    - **Limitation**: The standalone `LifecycleManager` does **not** support 
auth (`celeborn.auth.enabled` must be `false`), as the C++/Rust clients lack 
SASL support. Deploy it on a trusted network only.
    
    ### How was this patch tested?
    
    - **Unit tests (JVM)**: `LifecycleManagerDaemonArgumentsSuite` covers the 
happy paths plus help / unknown-arg / missing-arg / invalid-port branches and 
`applyArgsToConf` (13 tests). Run via Maven (`mvn -pl lifecycle-manager test`) 
and compiled/style-checked under sbt as a first-class module.
    - **Unit tests (Rust)**: `validate_connect_args` is covered by `cargo test 
-p celeborn-client` (codec/app_id/port validation) without requiring a cluster.
    - **Integration**: The Rust SDK was validated using the `data_sum_writer` / 
`data_sum_reader` examples (Rust ports of `DataSumWithWriterClient.cpp` / 
`DataSumWithReaderClient.cpp`), which write random numeric data across 
partitions and verify correctness by comparing partition sums between writer 
and reader. The `LifecycleManagerDaemon` was tested by starting it against a 
local Celeborn cluster (Master + Workers) and verifying the Rust examples 
connect, push, and read through the daemon.
    
    Closes #3677 from gavin9402/standalone_and_rust.
    
    Lead-authored-by: 夷羿 <[email protected]>
    Co-authored-by: Zhou <[email protected]>
    Signed-off-by: Shuang <[email protected]>
---
 .rat-excludes                                      |   5 +
 build/make-distribution.sh                         |  27 +-
 cpp/CMakeLists.txt                                 | 182 +++++++-
 cpp/celeborn/CMakeLists.txt                        |   1 +
 cpp/celeborn/{ => ffi}/CMakeLists.txt              |  29 +-
 cpp/celeborn/ffi/CelebornFfi.cc                    | 347 +++++++++++++++
 cpp/celeborn/ffi/CelebornFfi.h                     | 141 ++++++
 cpp/dummy.cc                                       |  27 ++
 cpp/exports.map                                    |  26 ++
 cpp/{celeborn/CMakeLists.txt => exports.txt}       |  16 +-
 lifecycle-manager/pom.xml                          |  68 +++
 .../lifecyclemanager/LifecycleManagerDaemon.scala  | 146 +++++++
 .../LifecycleManagerDaemonArguments.scala          | 145 +++++++
 .../LifecycleManagerDaemonArgumentsSuite.scala     | 185 ++++++++
 pom.xml                                            |   1 +
 project/CelebornBuild.scala                        |  14 +
 rust/.gitignore                                    |   7 +
 cpp/celeborn/CMakeLists.txt => rust/Cargo.toml     |  16 +-
 .../celeborn-client-sys/Cargo.toml                 |  17 +-
 rust/celeborn-client-sys/build.rs                  | 186 ++++++++
 rust/celeborn-client-sys/src/lib.rs                | 158 +++++++
 .rat-excludes => rust/celeborn-client/Cargo.toml   |  47 +-
 rust/celeborn-client/build.rs                      |  28 ++
 rust/celeborn-client/src/lib.rs                    | 478 +++++++++++++++++++++
 rust/examples/data_sum_reader.rs                   | 137 ++++++
 rust/examples/data_sum_writer.rs                   | 154 +++++++
 rust/resource/lib/README.md                        |  51 +++
 sbin/start-lifecycle-manager.sh                    | 217 ++++++++++
 sbin/stop-lifecycle-manager.sh                     | 165 +++++++
 29 files changed, 2947 insertions(+), 74 deletions(-)

diff --git a/.rat-excludes b/.rat-excludes
index 22c97119e..0cf626add 100644
--- a/.rat-excludes
+++ b/.rat-excludes
@@ -38,3 +38,8 @@ build/sbt-config/**
 **/benchmarks/**
 **/node_modules/**
 cpp/cmake/FindSodium.cmake
+cpp/build/**
+cpp/build_static/**
+cpp/thirdparty/**
+rust/Cargo.lock
+rust/.gitignore
diff --git a/build/make-distribution.sh b/build/make-distribution.sh
index e160b758d..67e97017e 100755
--- a/build/make-distribution.sh
+++ b/build/make-distribution.sh
@@ -146,7 +146,7 @@ function build_service {
   # Store the command as an array because $MVN variable might have spaces in 
it.
   # Normal quoting tricks don't work.
   # See: http://mywiki.wooledge.org/BashFAQ/050
-  BUILD_COMMAND=("$MVN" clean package $MVN_DIST_OPT -pl master,worker,cli -am 
$@)
+  BUILD_COMMAND=("$MVN" clean package $MVN_DIST_OPT -pl 
master,worker,cli,lifecycle-manager -am $@)
 
   # Actually build the jar
   echo -e "\nBuilding with..."
@@ -158,6 +158,7 @@ function build_service {
   mkdir -p "$DIST_DIR/master-jars"
   mkdir -p "$DIST_DIR/worker-jars"
   mkdir -p "$DIST_DIR/cli-jars"
+  mkdir -p "$DIST_DIR/lifecycle-manager-jars"
 
   ## Copy master jars
   cp "$PROJECT_DIR"/master/target/celeborn-master_$SCALA_VERSION-$VERSION.jar 
"$DIST_DIR/master-jars/"
@@ -177,6 +178,21 @@ function build_service {
   for jar in $(ls "$PROJECT_DIR/cli/target/scala-$SCALA_VERSION/jars"); do
     (cd $DIST_DIR/cli-jars; ln -snf "../jars/$jar" .)
   done
+  ## Copy lifecycle-manager jars
+  # lifecycle-manager depends on celeborn-client which is not a dependency of 
master/worker,
+  # so we copy its project-internal dependency jars that are missing from 
jars/.
+  for module_jar in \
+    
"$PROJECT_DIR/lifecycle-manager/target/celeborn-lifecycle-manager_$SCALA_VERSION-$VERSION.jar"
 \
+    "$PROJECT_DIR/client/target/celeborn-client_$SCALA_VERSION-$VERSION.jar"; 
do
+    jarname=$(basename "$module_jar")
+    if [ ! -f "$DIST_DIR/jars/$jarname" ]; then
+      cp "$module_jar" "$DIST_DIR/jars/"
+    fi
+  done
+  cp 
"$PROJECT_DIR"/lifecycle-manager/target/celeborn-lifecycle-manager_$SCALA_VERSION-$VERSION.jar
 "$DIST_DIR/lifecycle-manager-jars/"
+  for jar in $(ls "$DIST_DIR/jars"); do
+    (cd $DIST_DIR/lifecycle-manager-jars; ln -snf "../jars/$jar" .)
+  done
 }
 
 function build_spark_client {
@@ -304,12 +320,13 @@ function sbt_build_service {
 
   "${BUILD_COMMAND[@]}"
 
-  $SBT 
"celeborn-master/copyJars;celeborn-worker/copyJars;celeborn-cli/copyJars"
+  $SBT 
"celeborn-master/copyJars;celeborn-worker/copyJars;celeborn-cli/copyJars;celeborn-lifecycle-manager/copyJars"
 
   mkdir -p "$DIST_DIR/jars"
   mkdir -p "$DIST_DIR/master-jars"
   mkdir -p "$DIST_DIR/worker-jars"
   mkdir -p "$DIST_DIR/cli-jars"
+  mkdir -p "$DIST_DIR/lifecycle-manager-jars"
 
   ## Copy master jars
   cp 
"$PROJECT_DIR"/master/target/scala-$SCALA_VERSION/celeborn-master_$SCALA_VERSION-$VERSION.jar
 "$DIST_DIR/master-jars/"
@@ -329,6 +346,12 @@ function sbt_build_service {
   for jar in $(ls "$PROJECT_DIR/cli/target/scala-$SCALA_VERSION/jars"); do
     (cd $DIST_DIR/cli-jars; ln -snf "../jars/$jar" .)
   done
+  ## Copy lifecycle-manager jars
+  cp 
"$PROJECT_DIR"/lifecycle-manager/target/scala-$SCALA_VERSION/celeborn-lifecycle-manager_$SCALA_VERSION-$VERSION.jar
 "$DIST_DIR/lifecycle-manager-jars/"
+  cp "$PROJECT_DIR"/lifecycle-manager/target/scala-$SCALA_VERSION/jars/*.jar 
"$DIST_DIR/jars/"
+  for jar in $(ls 
"$PROJECT_DIR/lifecycle-manager/target/scala-$SCALA_VERSION/jars"); do
+    (cd $DIST_DIR/lifecycle-manager-jars; ln -snf "../jars/$jar" .)
+  done
 }
 
 function sbt_build_client {
diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt
index 8d54b62e3..151a70f65 100644
--- a/cpp/CMakeLists.txt
+++ b/cpp/CMakeLists.txt
@@ -23,6 +23,13 @@ enable_testing()
 
 set(CMAKE_CXX_STANDARD 17)
 set(CMAKE_CXX_STANDARD_REQUIRED True)
+
+# All internal static libs are whole-archived into the shared library
+# `celeborn_client`, which on Linux/ELF requires every contained object
+# to be position-independent. Without this, `ld` complains:
+#   relocation R_X86_64_TPOFF32 against hidden symbol ... can not be used
+#   when making a shared object
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
 message("Appending CMAKE_CXX_FLAGS with ${SCRIPT_CXX_FLAGS}")
 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SCRIPT_CXX_FLAGS}")
 if ("${TREAT_WARNINGS_AS_ERRORS}")
@@ -30,7 +37,33 @@ if ("${TREAT_WARNINGS_AS_ERRORS}")
 endif ()
 
 # Avoid folly::f14::detail::F14LinkCheck problem on x86-64 platform.
-set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -msse4.2")
+# -msse4.2 is an x86-only flag; emitting it on aarch64 (Apple Silicon or
+# aarch64 Linux) makes the compiler error out, so guard it by architecture.
+if (CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|amd64")
+    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -msse4.2")
+endif ()
+
+# Resolve the Homebrew prefix on macOS instead of hard-coding the
+# Apple-Silicon path. Intel Macs install under /usr/local while Apple
+# Silicon uses /opt/homebrew; `brew --prefix` reports whichever applies.
+if (APPLE)
+    if (DEFINED ENV{HOMEBREW_PREFIX})
+        set(HOMEBREW_PREFIX "$ENV{HOMEBREW_PREFIX}")
+    else ()
+        find_program(BREW_EXECUTABLE brew)
+        if (BREW_EXECUTABLE)
+            execute_process(
+                COMMAND ${BREW_EXECUTABLE} --prefix
+                OUTPUT_VARIABLE HOMEBREW_PREFIX
+                OUTPUT_STRIP_TRAILING_WHITESPACE)
+        elseif (EXISTS /opt/homebrew)
+            set(HOMEBREW_PREFIX "/opt/homebrew")
+        else ()
+            set(HOMEBREW_PREFIX "/usr/local")
+        endif ()
+    endif ()
+    message(STATUS "Using HOMEBREW_PREFIX=${HOMEBREW_PREFIX}")
+endif ()
 
 # Set CMAKE_BUILD_TYPE to 'Release' if it is not specified.
 if (NOT CMAKE_BUILD_TYPE)
@@ -102,7 +135,12 @@ if (APPLE)
     # Homebrew and only shared library is installed.
     find_package(gflags REQUIRED COMPONENTS shared)
 else ()
-    find_package(gflags REQUIRED COMPONENTS static)
+    # We build celeborn_client as a SHARED library (whole-archives every
+    # internal .a). Ubuntu's libgflags.a from apt is not compiled with
+    # -fPIC, so it cannot be linked into a .so. The shared gflags
+    # (libgflags.so) is PIC and works for both standalone executables and
+    # the aggregated shared library.
+    find_package(gflags REQUIRED COMPONENTS shared)
 endif ()
 
 find_package(glog REQUIRED)
@@ -177,9 +215,140 @@ endif()
 add_subdirectory(celeborn)
 
 # ---------------------------------------------------------------------------
-# Install rules — headers + static libraries
+# Aggregate SHARED library — bundles all internal static libs (including
+# the C ABI shim `celeborn_ffi`) into a single libceleborn_client.{so,dylib}.
+# Downstream language bindings (Rust / ctypes / etc.) link only this file
+# and never see folly / protobuf / glog / abseil.
+# ---------------------------------------------------------------------------
+add_library(celeborn_client SHARED dummy.cc)
+
+# Internal targets propagate plain library names like "xxhash" / "fmt" via
+# their link interface. Those need an actual search path on macOS where the
+# Homebrew prefix is not on ld's default path.
+if(APPLE)
+  target_link_directories(celeborn_client PRIVATE
+    ${HOMEBREW_PREFIX}/lib
+    ${HOMEBREW_PREFIX}/opt/openssl@3/lib)
+endif()
+
+set(_CELEBORN_INTERNAL_LIBS
+        celeborn_ffi client protocol network proto memory conf utils)
+
+if(APPLE)
+  # ld64: -force_load pulls every object out of each .a so no symbol gets
+  # stripped by the linker before it ships in the .dylib. Wrapping the
+  # target file path in a generator expression does not register a
+  # build-order dependency on those targets, so add it explicitly.
+  add_dependencies(celeborn_client ${_CELEBORN_INTERNAL_LIBS})
+  foreach(_lib IN LISTS _CELEBORN_INTERNAL_LIBS)
+    target_link_libraries(celeborn_client PRIVATE
+      "-Wl,-force_load" "$<TARGET_FILE:${_lib}>")
+  endforeach()
+else()
+  target_link_libraries(celeborn_client PRIVATE
+    -Wl,--whole-archive
+    ${_CELEBORN_INTERNAL_LIBS}
+    -Wl,--no-whole-archive)
+endif()
+
+# Third-party deps remain NEEDED entries inside the produced .so/.dylib and
+# resolve at runtime from the system loader path.
+target_link_libraries(celeborn_client PRIVATE
+  ${WANGLE} ${FIZZ}
+  ${LIBSODIUM_LIBRARY}
+  ${FOLLY_WITH_DEPENDENCIES}
+  ${LZ4_WITH_DEPENDENCIES}
+  ${GLOG} ${GFLAGS_LIBRARIES}
+  ${RE2}
+  protobuf::libprotobuf
+  OpenSSL::SSL OpenSSL::Crypto)
+
+# protobuf v22+ (a.k.a. 4.22.0+) switched to an abseil-based implementation and
+# pulls in dozens of absl_* sub-libraries at link time.  Older versions
+# (3.x / v21 and below) are self-contained and do not need abseil at all.
+#
+# Protobuf_VERSION is reported as the *library* version (e.g. "3.21.7"), while
+# the "v22" release corresponds to library version 4.22.0.  We therefore check
+# whether the major version is >= 4 (i.e. protobuf v22+).
+if(Protobuf_VERSION VERSION_GREATER_EQUAL "4.0.0")
+  message(STATUS "Protobuf ${Protobuf_VERSION} requires abseil — searching …")
+  find_package(absl CONFIG QUIET)
+  if(absl_FOUND)
+    if(TARGET absl::absl)
+      target_link_libraries(celeborn_client PRIVATE absl::absl)
+    else()
+      target_link_libraries(celeborn_client PRIVATE
+        absl::base absl::strings absl::status absl::statusor
+        absl::synchronization absl::time absl::log absl::log_internal_check_op)
+    endif()
+  else()
+    # No CMake config — fall back to globbing the platform library directory.
+    if(APPLE)
+      set(_ABSEIL_LIB_DIR "${HOMEBREW_PREFIX}/lib")
+      set(_ABSEIL_LIB_EXT "dylib")
+    else()
+      set(_ABSEIL_LIB_EXT "so")
+      execute_process(
+        COMMAND ${CMAKE_CXX_COMPILER} -dumpmachine
+        OUTPUT_VARIABLE _MULTIARCH_TRIPLET
+        OUTPUT_STRIP_TRAILING_WHITESPACE
+        ERROR_QUIET)
+      set(_ABSEIL_SEARCH_DIRS
+        "/usr/lib/${_MULTIARCH_TRIPLET}"
+        "/usr/local/lib"
+        "/usr/lib64"
+        "/usr/lib")
+      set(_ABSEIL_LIB_DIR "")
+      foreach(_dir IN LISTS _ABSEIL_SEARCH_DIRS)
+        file(GLOB _probe "${_dir}/libabsl_*.${_ABSEIL_LIB_EXT}")
+        if(_probe)
+          set(_ABSEIL_LIB_DIR "${_dir}")
+          break()
+        endif()
+      endforeach()
+    endif()
+
+    file(GLOB _ABSEIL_LIBS "${_ABSEIL_LIB_DIR}/libabsl_*.${_ABSEIL_LIB_EXT}")
+    if(_ABSEIL_LIBS)
+      message(STATUS "Found abseil libraries in ${_ABSEIL_LIB_DIR}")
+      target_link_libraries(celeborn_client PRIVATE ${_ABSEIL_LIBS})
+    else()
+      message(FATAL_ERROR
+        "Could not locate libabsl_*.${_ABSEIL_LIB_EXT}. Protobuf "
+        "${Protobuf_VERSION} depends on abseil; install abseil-cpp "
+        "(providing absl CMake config or libabsl_*.${_ABSEIL_LIB_EXT}) "
+        "or point CMAKE_PREFIX_PATH at it.")
+    endif()
+  endif()
+else()
+  message(STATUS "Protobuf ${Protobuf_VERSION} does not require abseil — 
skipping absl search")
+endif()
+
+# Hide every non-celeborn symbol so the dylib does not collide with the
+# host process's own protobuf / abseil / folly runtimes.
+set_target_properties(celeborn_client PROPERTIES
+  CXX_VISIBILITY_PRESET hidden
+  VISIBILITY_INLINES_HIDDEN ON)
+
+if(APPLE)
+  target_link_options(celeborn_client PRIVATE
+    "-Wl,-exported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/exports.txt")
+  set_target_properties(celeborn_client PROPERTIES
+    INSTALL_RPATH 
"@loader_path;${HOMEBREW_PREFIX}/lib;${HOMEBREW_PREFIX}/opt/openssl@3/lib"
+    BUILD_WITH_INSTALL_RPATH TRUE)
+else()
+  target_link_options(celeborn_client PRIVATE
+    "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exports.map"
+    "-Wl,--exclude-libs,ALL")
+  set_target_properties(celeborn_client PROPERTIES
+    INSTALL_RPATH "$ORIGIN"
+    BUILD_WITH_INSTALL_RPATH TRUE)
+endif()
+
+# ---------------------------------------------------------------------------
+# Install rules — headers + the aggregated shared library.
+# Static libraries are still installed to keep the legacy build path working.
 # ---------------------------------------------------------------------------
-# Install all public headers preserving directory structure.
 install(
   DIRECTORY celeborn/
   DESTINATION include/celeborn
@@ -187,15 +356,14 @@ install(
   PATTERN "tests" EXCLUDE
 )
 
-# Install the generated proto header.
 install(
   FILES ${ProtoGenHeader}
   DESTINATION include/celeborn/proto
 )
 
-# Install static libraries.
 install(
-  TARGETS client conf memory network proto protocol utils
+  TARGETS celeborn_client celeborn_ffi client conf memory network proto 
protocol utils
   ARCHIVE DESTINATION lib
   LIBRARY DESTINATION lib
+  RUNTIME DESTINATION bin
 )
diff --git a/cpp/celeborn/CMakeLists.txt b/cpp/celeborn/CMakeLists.txt
index 1588488a7..fc6d9b2be 100644
--- a/cpp/celeborn/CMakeLists.txt
+++ b/cpp/celeborn/CMakeLists.txt
@@ -19,6 +19,7 @@ add_subdirectory(conf)
 add_subdirectory(protocol)
 add_subdirectory(network)
 add_subdirectory(client)
+add_subdirectory(ffi)
 
 if(CELEBORN_BUILD_TESTS)
     add_subdirectory(tests)
diff --git a/cpp/celeborn/CMakeLists.txt b/cpp/celeborn/ffi/CMakeLists.txt
similarity index 58%
copy from cpp/celeborn/CMakeLists.txt
copy to cpp/celeborn/ffi/CMakeLists.txt
index 1588488a7..48e5a6356 100644
--- a/cpp/celeborn/CMakeLists.txt
+++ b/cpp/celeborn/ffi/CMakeLists.txt
@@ -12,14 +12,23 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-add_subdirectory(proto)
-add_subdirectory(memory)
-add_subdirectory(utils)
-add_subdirectory(conf)
-add_subdirectory(protocol)
-add_subdirectory(network)
-add_subdirectory(client)
 
-if(CELEBORN_BUILD_TESTS)
-    add_subdirectory(tests)
-endif()
+# C ABI shim that wraps celeborn::client::ShuffleClient* in pure-C entry
+# points. Built as a STATIC archive that is whole-archived into the
+# aggregated libceleborn_client.{so,dylib} (see top-level CMakeLists.txt).
+add_library(
+        celeborn_ffi
+        STATIC
+        CelebornFfi.cc)
+
+target_include_directories(celeborn_ffi PUBLIC
+        ${CMAKE_SOURCE_DIR}
+        ${CMAKE_BINARY_DIR})
+
+target_link_libraries(
+        celeborn_ffi
+        client
+        conf
+        ${FOLLY_WITH_DEPENDENCIES}
+        ${GLOG}
+        ${GFLAGS_LIBRARIES})
diff --git a/cpp/celeborn/ffi/CelebornFfi.cc b/cpp/celeborn/ffi/CelebornFfi.cc
new file mode 100644
index 000000000..10a00081a
--- /dev/null
+++ b/cpp/celeborn/ffi/CelebornFfi.cc
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "celeborn/ffi/CelebornFfi.h"
+
+#include <cstdlib>
+#include <cstring>
+#include <exception>
+#include <memory>
+#include <new>
+#include <string>
+#include <vector>
+
+#include "celeborn/client/ShuffleClient.h"
+#include "celeborn/client/reader/CelebornInputStream.h"
+#include "celeborn/conf/CelebornConf.h"
+
+namespace {
+
+struct ClientImpl {
+  std::shared_ptr<celeborn::conf::CelebornConf> conf;
+  std::shared_ptr<celeborn::client::ShuffleClientEndpoint> endpoint;
+  std::shared_ptr<celeborn::client::ShuffleClientImpl> client;
+  std::string app_id;
+  std::string lifecycle_manager_host;
+};
+
+// One open partition stream. The stream references the owning
+// ShuffleClient, so callers must close every reader before shutting down
+// the client.
+struct PartitionReaderImpl {
+  std::unique_ptr<celeborn::client::CelebornInputStream> stream;
+};
+
+inline ClientImpl* as_impl(celeborn_ffi_handle* h) {
+  return reinterpret_cast<ClientImpl*>(h);
+}
+
+inline PartitionReaderImpl* as_reader_impl(celeborn_ffi_partition_reader* r) {
+  return reinterpret_cast<PartitionReaderImpl*>(r);
+}
+
+char* dup_message(const std::string& msg) {
+  char* out = static_cast<char*>(std::malloc(msg.size() + 1));
+  if (!out) {
+    return nullptr;
+  }
+  std::memcpy(out, msg.data(), msg.size());
+  out[msg.size()] = '\0';
+  return out;
+}
+
+void set_error(char** err_out, const std::string& msg) {
+  if (err_out) {
+    *err_out = dup_message("celeborn-ffi: " + msg);
+  }
+}
+
+template <typename Fn>
+celeborn_ffi_status guarded(char** err_out, Fn&& fn) {
+  try {
+    fn();
+    return CELEBORN_FFI_OK;
+  } catch (const std::exception& e) {
+    set_error(err_out, e.what());
+    return CELEBORN_FFI_ERROR;
+  } catch (...) {
+    set_error(err_out, "unknown C++ exception");
+    return CELEBORN_FFI_ERROR;
+  }
+}
+
+// Reject null pointers at the FFI boundary so that misuse from non-Rust
+// callers surfaces as CELEBORN_FFI_ERROR with a descriptive message rather
+// than a SIGSEGV inside the C++ implementation.
+#define CELEBORN_FFI_REQUIRE_NON_NULL(ptr, err_out)                     \
+  do {                                                                  \
+    if ((ptr) == nullptr) {                                             \
+      set_error((err_out), "null pointer for argument '" #ptr "'");     \
+      return CELEBORN_FFI_ERROR;                                        \
+    }                                                                   \
+  } while (0)
+
+} // namespace
+
+extern "C" {
+
+void celeborn_ffi_free_error(char* err) {
+  std::free(err);
+}
+
+void celeborn_ffi_free_buffer(uint8_t* data) {
+  delete[] data;
+}
+
+celeborn_ffi_handle* celeborn_ffi_create_client(
+    const char* app_id,
+    size_t app_id_len,
+    int32_t push_buffer_max_size,
+    const char* codec,
+    size_t codec_len,
+    char** err_out) {
+  if (app_id == nullptr && app_id_len > 0) {
+    set_error(err_out, "null pointer for argument 'app_id'");
+    return nullptr;
+  }
+  if (codec == nullptr && codec_len > 0) {
+    set_error(err_out, "null pointer for argument 'codec'");
+    return nullptr;
+  }
+  try {
+    auto impl = std::make_unique<ClientImpl>();
+    if (app_id_len > 0) {
+      impl->app_id.assign(app_id, app_id_len);
+    }
+    impl->conf = std::make_shared<celeborn::conf::CelebornConf>();
+
+    if (push_buffer_max_size > 0) {
+      impl->conf->registerProperty(
+          celeborn::conf::CelebornConf::kClientPushBufferMaxSize,
+          std::to_string(push_buffer_max_size) + "b");
+    }
+    if (codec_len > 0) {
+      impl->conf->registerProperty(
+          celeborn::conf::CelebornConf::kShuffleCompressionCodec,
+          std::string(codec, codec_len));
+    }
+
+    impl->endpoint =
+        std::make_shared<celeborn::client::ShuffleClientEndpoint>(impl->conf);
+    impl->client = celeborn::client::ShuffleClientImpl::create(
+        impl->app_id, impl->conf, *(impl->endpoint));
+    return reinterpret_cast<celeborn_ffi_handle*>(impl.release());
+  } catch (const std::exception& e) {
+    set_error(err_out, e.what());
+    return nullptr;
+  } catch (...) {
+    set_error(err_out, "unknown C++ exception");
+    return nullptr;
+  }
+}
+
+celeborn_ffi_status celeborn_ffi_setup_lifecycle_manager(
+    celeborn_ffi_handle* handle,
+    const char* host,
+    size_t host_len,
+    int32_t port,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  if (host == nullptr && host_len > 0) {
+    set_error(err_out, "null pointer for argument 'host'");
+    return CELEBORN_FFI_ERROR;
+  }
+  return guarded(err_out, [&] {
+    auto* impl = as_impl(handle);
+    if (host_len > 0) {
+      impl->lifecycle_manager_host.assign(host, host_len);
+    } else {
+      impl->lifecycle_manager_host.clear();
+    }
+    impl->client->setupLifecycleManagerRef(impl->lifecycle_manager_host, port);
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_shutdown(
+    celeborn_ffi_handle* handle,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  return guarded(err_out, [&] { as_impl(handle)->client->shutdown(); });
+}
+
+celeborn_ffi_status celeborn_ffi_push_data(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t map_id,
+    int32_t attempt_id,
+    int32_t partition_id,
+    const uint8_t* data,
+    size_t data_len,
+    int32_t num_mappers,
+    int32_t num_partitions,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  if (data == nullptr && data_len > 0) {
+    set_error(err_out, "null pointer for argument 'data'");
+    return CELEBORN_FFI_ERROR;
+  }
+  return guarded(err_out, [&] {
+    as_impl(handle)->client->pushData(
+        shuffle_id,
+        map_id,
+        attempt_id,
+        partition_id,
+        data,
+        0,
+        static_cast<int>(data_len),
+        num_mappers,
+        num_partitions);
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_mapper_end(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t map_id,
+    int32_t attempt_id,
+    int32_t num_mappers,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  return guarded(err_out, [&] {
+    as_impl(handle)->client->mapperEnd(
+        shuffle_id, map_id, attempt_id, num_mappers);
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_update_reducer_file_group(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  return guarded(err_out, [&] {
+    as_impl(handle)->client->updateReducerFileGroup(shuffle_id);
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_read_partition_full(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t partition_id,
+    int32_t attempt_number,
+    int32_t start_map_index,
+    int32_t end_map_index,
+    uint8_t** data_out,
+    size_t* len_out,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  CELEBORN_FFI_REQUIRE_NON_NULL(data_out, err_out);
+  CELEBORN_FFI_REQUIRE_NON_NULL(len_out, err_out);
+  return guarded(err_out, [&] {
+    auto stream = as_impl(handle)->client->readPartition(
+        shuffle_id,
+        partition_id,
+        attempt_number,
+        start_map_index,
+        end_map_index);
+
+    constexpr size_t kReadBufSize = 64 * 1024;
+    std::vector<uint8_t> accumulated;
+    accumulated.reserve(kReadBufSize);
+    std::vector<uint8_t> buf(kReadBufSize);
+
+    while (true) {
+      int n = stream->read(buf.data(), 0, buf.size());
+      if (n == -1) {
+        break;
+      }
+      if (n <= 0) {
+        throw std::runtime_error(
+            "CelebornInputStream::read returned unexpected non-positive " +
+            std::to_string(n));
+      }
+      accumulated.insert(accumulated.end(), buf.data(), buf.data() + n);
+    }
+
+    auto* out = new uint8_t[accumulated.size()];
+    std::memcpy(out, accumulated.data(), accumulated.size());
+    *data_out = out;
+    *len_out = accumulated.size();
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_open_partition_reader(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t partition_id,
+    int32_t attempt_number,
+    int32_t start_map_index,
+    int32_t end_map_index,
+    celeborn_ffi_partition_reader** reader_out,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(handle, err_out);
+  CELEBORN_FFI_REQUIRE_NON_NULL(reader_out, err_out);
+  return guarded(err_out, [&] {
+    auto reader = std::make_unique<PartitionReaderImpl>();
+    reader->stream = as_impl(handle)->client->readPartition(
+        shuffle_id,
+        partition_id,
+        attempt_number,
+        start_map_index,
+        end_map_index);
+    *reader_out =
+        reinterpret_cast<celeborn_ffi_partition_reader*>(reader.release());
+  });
+}
+
+celeborn_ffi_status celeborn_ffi_read_partition_chunk(
+    celeborn_ffi_partition_reader* reader,
+    uint8_t* buf,
+    size_t buf_len,
+    size_t* bytes_read,
+    char** err_out) {
+  CELEBORN_FFI_REQUIRE_NON_NULL(reader, err_out);
+  CELEBORN_FFI_REQUIRE_NON_NULL(bytes_read, err_out);
+  if (buf == nullptr && buf_len > 0) {
+    set_error(err_out, "null pointer for argument 'buf'");
+    return CELEBORN_FFI_ERROR;
+  }
+  return guarded(err_out, [&] {
+    if (buf_len == 0) {
+      *bytes_read = 0;
+      return;
+    }
+    int n = as_reader_impl(reader)->stream->read(buf, 0, buf_len);
+    if (n == -1) {
+      // EOF — surface as 0 bytes read to match std::io::Read semantics.
+      *bytes_read = 0;
+      return;
+    }
+    if (n < 0) {
+      throw std::runtime_error(
+          "CelebornInputStream::read returned unexpected negative " +
+          std::to_string(n));
+    }
+    *bytes_read = static_cast<size_t>(n);
+  });
+}
+
+void celeborn_ffi_close_partition_reader(
+    celeborn_ffi_partition_reader* reader) {
+  delete as_reader_impl(reader);
+}
+
+} // extern "C"
diff --git a/cpp/celeborn/ffi/CelebornFfi.h b/cpp/celeborn/ffi/CelebornFfi.h
new file mode 100644
index 000000000..e686113ba
--- /dev/null
+++ b/cpp/celeborn/ffi/CelebornFfi.h
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Pure C ABI shim around the celeborn C++ shuffle client. Lives inside
+// libceleborn_client.{so,dylib} so downstream language bindings (Rust /
+// Python via ctypes / etc.) only need to link the single shared library
+// and never pull folly / protobuf / glog / abseil headers themselves.
+#ifndef CELEBORN_FFI_WRAPPER_H
+#define CELEBORN_FFI_WRAPPER_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Opaque handle. Allocated by celeborn_ffi_create_client; intentionally
+// never freed (folly's IOThreadPoolExecutor::join() races with
+// TransportClient teardown — calling the C++ destructor produces SIGSEGV).
+typedef struct celeborn_ffi_handle celeborn_ffi_handle;
+
+// Opaque handle for a single open partition reader. Allocated by
+// celeborn_ffi_open_partition_reader; released by
+// celeborn_ffi_close_partition_reader. Holds a CelebornInputStream that
+// references the owning client, so it must be closed before its
+// celeborn_ffi_handle is shut down.
+typedef struct celeborn_ffi_partition_reader celeborn_ffi_partition_reader;
+
+// Status codes. Every fallible function returns CELEBORN_FFI_OK on success
+// and writes a heap-allocated, NUL-terminated message into *err_out on
+// failure (caller must release with celeborn_ffi_free_error).
+typedef int32_t celeborn_ffi_status;
+#define CELEBORN_FFI_OK 0
+#define CELEBORN_FFI_ERROR 1
+
+void celeborn_ffi_free_error(char* err);
+
+// Releases a buffer returned via celeborn_ffi_read_partition_full.
+void celeborn_ffi_free_buffer(uint8_t* data);
+
+// Returns NULL on failure; in that case *err_out is set.
+celeborn_ffi_handle* celeborn_ffi_create_client(
+    const char* app_id,
+    size_t app_id_len,
+    int32_t push_buffer_max_size,
+    const char* codec,
+    size_t codec_len,
+    char** err_out);
+
+celeborn_ffi_status celeborn_ffi_setup_lifecycle_manager(
+    celeborn_ffi_handle* handle,
+    const char* host,
+    size_t host_len,
+    int32_t port,
+    char** err_out);
+
+celeborn_ffi_status celeborn_ffi_shutdown(
+    celeborn_ffi_handle* handle,
+    char** err_out);
+
+celeborn_ffi_status celeborn_ffi_push_data(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t map_id,
+    int32_t attempt_id,
+    int32_t partition_id,
+    const uint8_t* data,
+    size_t data_len,
+    int32_t num_mappers,
+    int32_t num_partitions,
+    char** err_out);
+
+celeborn_ffi_status celeborn_ffi_mapper_end(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t map_id,
+    int32_t attempt_id,
+    int32_t num_mappers,
+    char** err_out);
+
+celeborn_ffi_status celeborn_ffi_update_reducer_file_group(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    char** err_out);
+
+// On success, *data_out is a heap buffer (free with celeborn_ffi_free_buffer)
+// and *len_out is its byte length.
+celeborn_ffi_status celeborn_ffi_read_partition_full(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t partition_id,
+    int32_t attempt_number,
+    int32_t start_map_index,
+    int32_t end_map_index,
+    uint8_t** data_out,
+    size_t* len_out,
+    char** err_out);
+
+// On success, *reader_out is a heap-allocated reader that must eventually
+// be released with celeborn_ffi_close_partition_reader.
+celeborn_ffi_status celeborn_ffi_open_partition_reader(
+    celeborn_ffi_handle* handle,
+    int32_t shuffle_id,
+    int32_t partition_id,
+    int32_t attempt_number,
+    int32_t start_map_index,
+    int32_t end_map_index,
+    celeborn_ffi_partition_reader** reader_out,
+    char** err_out);
+
+// Reads up to buf_len bytes into buf. *bytes_read is set to the number of
+// bytes actually read; 0 indicates EOF (matching Rust std::io::Read).
+celeborn_ffi_status celeborn_ffi_read_partition_chunk(
+    celeborn_ffi_partition_reader* reader,
+    uint8_t* buf,
+    size_t buf_len,
+    size_t* bytes_read,
+    char** err_out);
+
+void celeborn_ffi_close_partition_reader(celeborn_ffi_partition_reader* 
reader);
+
+#ifdef __cplusplus
+} // extern "C"
+#endif
+
+#endif // CELEBORN_FFI_WRAPPER_H
diff --git a/cpp/dummy.cc b/cpp/dummy.cc
new file mode 100644
index 000000000..ec4d1b542
--- /dev/null
+++ b/cpp/dummy.cc
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Placeholder TU for the aggregate `celeborn_client` SHARED target.
+// All real code is brought in via -Wl,-force_load / --whole-archive of the
+// internal static libraries (celeborn_ffi / client / protocol / network /
+// proto / memory / conf / utils). CMake still requires at least one source
+// file to create the shared library target.
+namespace celeborn_ffi {
+extern "C" const char* celeborn_client_build_tag() {
+  return "celeborn-client-shared";
+}
+} // namespace celeborn_ffi
diff --git a/cpp/exports.map b/cpp/exports.map
new file mode 100644
index 000000000..0db2907af
--- /dev/null
+++ b/cpp/exports.map
@@ -0,0 +1,26 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{
+  global:
+    extern "C++" {
+      celeborn::*;
+    };
+    celeborn_*;
+    celeborn_ffi_*;
+  local:
+    *;
+};
diff --git a/cpp/celeborn/CMakeLists.txt b/cpp/exports.txt
similarity index 69%
copy from cpp/celeborn/CMakeLists.txt
copy to cpp/exports.txt
index 1588488a7..0e3b1a549 100644
--- a/cpp/celeborn/CMakeLists.txt
+++ b/cpp/exports.txt
@@ -12,14 +12,10 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-add_subdirectory(proto)
-add_subdirectory(memory)
-add_subdirectory(utils)
-add_subdirectory(conf)
-add_subdirectory(protocol)
-add_subdirectory(network)
-add_subdirectory(client)
 
-if(CELEBORN_BUILD_TESTS)
-    add_subdirectory(tests)
-endif()
+# macOS ld64 -exported_symbols_list patterns. Mangled C++ names of
+# celeborn::* symbols contain the substring "celeborn", and the C ABI
+# shim exports celeborn_ffi_* symbols as plain C (which on Mach-O become
+# _celeborn_ffi_*). The single leading-glob pattern below matches both
+# (celeborn_ffi_* is a subset of *celeborn*).
+*celeborn*
diff --git a/lifecycle-manager/pom.xml b/lifecycle-manager/pom.xml
new file mode 100644
index 000000000..68177c3e4
--- /dev/null
+++ b/lifecycle-manager/pom.xml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~    http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.apache.celeborn</groupId>
+    <artifactId>celeborn-parent_${scala.binary.version}</artifactId>
+    <version>${project.version}</version>
+    <relativePath>../pom.xml</relativePath>
+  </parent>
+
+  <artifactId>celeborn-lifecycle-manager_${scala.binary.version}</artifactId>
+  <packaging>jar</packaging>
+  <name>Celeborn Lifecycle Manager</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      <artifactId>celeborn-service_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      <artifactId>celeborn-client_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      <artifactId>celeborn-common_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
+    <!-- Test dependencies -->
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      <artifactId>celeborn-common_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.scalatest</groupId>
+      <artifactId>scalatest_${scala.binary.version}</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git 
a/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemon.scala
 
b/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemon.scala
new file mode 100644
index 000000000..70abca62d
--- /dev/null
+++ 
b/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemon.scala
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.celeborn.server.lifecyclemanager
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicReference
+
+import org.apache.celeborn.client.LifecycleManager
+import org.apache.celeborn.common.CelebornConf
+import org.apache.celeborn.common.internal.Logging
+import org.apache.celeborn.common.util.{SignalUtils, Utils}
+
+object LifecycleManagerDaemon extends Logging {
+
+  private[lifecyclemanager] val shutdownLatch: CountDownLatch = new 
CountDownLatch(1)
+
+  private[lifecyclemanager] val currentInstance: 
AtomicReference[LifecycleManager] =
+    new AtomicReference[LifecycleManager]()
+
+  private[lifecyclemanager] var exitFn: Int => Unit =
+    (code: Int) => System.exit(code)
+
+  def main(args: Array[String]): Unit = {
+    SignalUtils.registerLogger(log)
+
+    val parsedArgs = LifecycleManagerDaemonArguments.parseOrExit(args)
+    val conf = new CelebornConf()
+
+    // Load properties file before applying CLI args
+    Utils.loadDefaultCelebornProperties(conf, parsedArgs.propertiesFile.orNull)
+
+    applyArgsToConf(parsedArgs, conf)
+
+    // Auth check: standalone LM does not support auth (cpp/Rust client lacks 
SASL).
+    //
+    // DEPLOYMENT WARNING: with auth disabled, this daemon exposes shuffle
+    // registration and slot allocation to anything that can reach its RPC 
port.
+    // It performs no authentication of incoming clients. Operators MUST bind 
it
+    // to a trusted network only (e.g. set --host to a private interface and
+    // restrict the RPC port via firewall / security groups / network policy);
+    // never expose the port on an untrusted or public network.
+    if (conf.authEnabledOnClient) {
+      logError(
+        "Standalone LifecycleManager does not support auth " +
+          "(cpp/Rust client lacks SASL); set celeborn.auth.enabled=false")
+      exitFn(1)
+      return
+    }
+
+    logWarning(
+      "Standalone LifecycleManager runs WITHOUT authentication. Ensure its RPC 
" +
+        "port is reachable only from trusted networks (bind to a private " +
+        "interface and restrict access via firewall / network policy).")
+
+    // Propagate --host to Utils so LifecycleManager binds to the requested 
hostname
+    parsedArgs.host.foreach { host =>
+      logInfo(s"Setting custom hostname from --host: $host")
+      Utils.setCustomHostname(host)
+    }
+
+    logInfo(s"Parsed args: appId=${parsedArgs.appId}, port=${parsedArgs.port}, 
" +
+      s"masterEndpoints=${parsedArgs.masterEndpoints}")
+
+    try {
+      val lm = new LifecycleManager(parsedArgs.appId, conf)
+      currentInstance.set(lm)
+
+      installShutdownHook(conf)
+
+      // scalastyle:off println
+      println(s"LifecycleManager bound at ${lm.getHost}:${lm.getPort}")
+      // scalastyle:on println
+
+      logInfo("shutdown hook installed; press Ctrl-C to stop.")
+
+      // Block until the shutdown hook fires (Ctrl-C / SIGTERM) and counts the
+      // latch down. The hook is what drives the actual JVM exit, so there is 
no
+      // need for an explicit exitFn(0) here — calling System.exit again from 
the
+      // main thread while a shutdown hook is already running is redundant.
+      shutdownLatch.await()
+    } catch {
+      case e: Exception =>
+        logError("Failed to start LifecycleManager", e)
+        exitFn(1)
+    }
+  }
+
+  private[lifecyclemanager] def applyArgsToConf(
+      args: LifecycleManagerDaemonArguments,
+      conf: CelebornConf): Unit = {
+    conf.set(CelebornConf.MASTER_ENDPOINTS.key, args.masterEndpoints)
+    conf.set(CelebornConf.CLIENT_SHUFFLE_MANAGER_PORT.key, args.port.toString)
+  }
+
+  private def installShutdownHook(conf: CelebornConf): Unit = {
+    val shutdownTimeoutMs = conf.appHeartbeatTimeoutMs / 2
+
+    // Watchdog: force halt if shutdown takes too long
+    val watchdog = new Thread("celeborn-lm-shutdown-watchdog") {
+      override def run(): Unit = {
+        try {
+          Thread.sleep(shutdownTimeoutMs)
+        } catch {
+          // Nothing interrupts this watchdog today, but if some future caller
+          // ever does, treat the interruption itself as a sign that shutdown 
is
+          // wedged and force a halt rather than silently exiting.
+          case _: InterruptedException =>
+            Thread.currentThread().interrupt()
+        }
+        logError(s"Shutdown exceeded ${shutdownTimeoutMs}ms, forcing halt")
+        Runtime.getRuntime.halt(2)
+      }
+    }
+    watchdog.setDaemon(true)
+
+    Runtime.getRuntime.addShutdownHook(new Thread("celeborn-lm-shutdown") {
+      override def run(): Unit = {
+        watchdog.start()
+        val lm = currentInstance.get()
+        if (lm != null) {
+          try {
+            lm.stop()
+          } catch {
+            case t: Throwable => logError("lm.stop() failed", t)
+          }
+        }
+        shutdownLatch.countDown()
+      }
+    })
+  }
+}
diff --git 
a/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArguments.scala
 
b/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArguments.scala
new file mode 100644
index 000000000..3b3286ef1
--- /dev/null
+++ 
b/lifecycle-manager/src/main/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArguments.scala
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.celeborn.server.lifecyclemanager
+
+import scala.annotation.tailrec
+
+import org.apache.celeborn.common.util.IntParam
+
+private[lifecyclemanager] case class LifecycleManagerDaemonArguments(
+    appId: String,
+    masterEndpoints: String,
+    port: Int,
+    host: Option[String],
+    propertiesFile: Option[String])
+
+/**
+ * Signals that argument parsing requested termination. Carries the intended
+ * process exit code so callers (e.g. `main`) can exit, while unit tests can
+ * assert on the code and message without the JVM actually shutting down.
+ *
+ * `exitCode == 0` denotes a successful, user-requested `--help`.
+ */
+private[lifecyclemanager] class ArgumentParseException(
+    val exitCode: Int,
+    message: String) extends RuntimeException(message)
+
+private[lifecyclemanager] object LifecycleManagerDaemonArguments {
+
+  private val MIN_USER_PORT = 1024
+
+  /**
+   * Pure parser: validates `args` and either returns the parsed arguments or
+   * throws [[ArgumentParseException]]. It performs no I/O and never calls
+   * `sys.exit`, so every branch (help / unknown / missing-arg / bad-port) is
+   * unit-testable. Use [[parseOrExit]] for the process entry point.
+   */
+  def parse(args: Array[String]): LifecycleManagerDaemonArguments = {
+    var appId: Option[String] = None
+    var masterEndpoints: Option[String] = None
+    var port: Option[Int] = None
+    var host: Option[String] = None
+    var propertiesFile: Option[String] = None
+
+    @tailrec
+    def doParse(remaining: List[String]): Unit = remaining match {
+      case "--app-id" :: value :: tail =>
+        appId = Some(value)
+        doParse(tail)
+
+      case "--master-endpoints" :: value :: tail =>
+        masterEndpoints = Some(value)
+        doParse(tail)
+
+      case ("--port" | "-p") :: IntParam(value) :: tail =>
+        port = Some(value)
+        doParse(tail)
+
+      // Only the long form is accepted for host: `-h` conventionally means
+      // help, so reserving it for --host would be surprising.
+      case "--host" :: value :: tail =>
+        host = Some(value)
+        doParse(tail)
+
+      case "--properties-file" :: value :: tail =>
+        propertiesFile = Some(value)
+        doParse(tail)
+
+      case ("--help" | "-h") :: _ =>
+        throw new ArgumentParseException(0, usage)
+
+      case Nil => // done
+
+      case unknown :: _ =>
+        throw new ArgumentParseException(1, s"Unknown argument: 
$unknown\n$usage")
+    }
+
+    doParse(args.toList)
+
+    if (appId.isEmpty) {
+      throw new ArgumentParseException(1, s"Error: --app-id is 
required.\n$usage")
+    }
+    if (masterEndpoints.isEmpty) {
+      throw new ArgumentParseException(1, s"Error: --master-endpoints is 
required.\n$usage")
+    }
+    if (port.isEmpty) {
+      throw new ArgumentParseException(1, s"Error: --port is 
required.\n$usage")
+    }
+    if (port.get < MIN_USER_PORT) {
+      throw new ArgumentParseException(
+        1,
+        s"Error: --port must be >= $MIN_USER_PORT, got ${port.get}.\n$usage")
+    }
+
+    LifecycleManagerDaemonArguments(
+      appId = appId.get,
+      masterEndpoints = masterEndpoints.get,
+      port = port.get,
+      host = host,
+      propertiesFile = propertiesFile)
+  }
+
+  /**
+   * Process entry point wrapper around [[parse]]: prints the message carried 
by
+   * an [[ArgumentParseException]] and exits with its code.
+   */
+  def parseOrExit(args: Array[String]): LifecycleManagerDaemonArguments = {
+    try {
+      parse(args)
+    } catch {
+      case e: ArgumentParseException =>
+        // scalastyle:off println
+        System.err.println(e.getMessage)
+        // scalastyle:on println
+        sys.exit(e.exitCode)
+    }
+  }
+
+  val usage: String =
+    """Usage: LifecycleManagerDaemon [options]
+      |
+      |Options:
+      |  --app-id ID                  Application unique identifier (required)
+      |  --master-endpoints ENDPOINTS Comma-separated master host:port list 
(required)
+      |  -p PORT, --port PORT         Port for LifecycleManager to listen on 
(required, >= 1024)
+      |  --host HOST                  Hostname to bind (optional, default: 
auto-detect)
+      |  --properties-file FILE       Path to a custom Celeborn properties 
file,
+      |                               default is conf/celeborn-defaults.conf
+      |  -h, --help                   Print this help message
+      |""".stripMargin
+}
diff --git 
a/lifecycle-manager/src/test/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArgumentsSuite.scala
 
b/lifecycle-manager/src/test/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArgumentsSuite.scala
new file mode 100644
index 000000000..bd2cd0b2b
--- /dev/null
+++ 
b/lifecycle-manager/src/test/scala/org/apache/celeborn/server/lifecyclemanager/LifecycleManagerDaemonArgumentsSuite.scala
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.celeborn.server.lifecyclemanager
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.celeborn.common.CelebornConf
+import org.apache.celeborn.common.internal.Logging
+
+class LifecycleManagerDaemonArgumentsSuite extends AnyFunSuite with Logging {
+
+  test("parse all required arguments") {
+    val args = Array(
+      "--app-id",
+      "test-app-1",
+      "--master-endpoints",
+      "host1:9097,host2:9097",
+      "--port",
+      "39099")
+    val parsed = LifecycleManagerDaemonArguments.parse(args)
+    assert(parsed.appId === "test-app-1")
+    assert(parsed.masterEndpoints === "host1:9097,host2:9097")
+    assert(parsed.port === 39099)
+    assert(parsed.host.isEmpty)
+    assert(parsed.propertiesFile.isEmpty)
+  }
+
+  test("parse all arguments including optional ones") {
+    val args = Array(
+      "--app-id",
+      "my-app",
+      "--master-endpoints",
+      "localhost:9097",
+      "--port",
+      "40000",
+      "--host",
+      "my-host",
+      "--properties-file",
+      "/tmp/celeborn.conf")
+    val parsed = LifecycleManagerDaemonArguments.parse(args)
+    assert(parsed.appId === "my-app")
+    assert(parsed.masterEndpoints === "localhost:9097")
+    assert(parsed.port === 40000)
+    assert(parsed.host === Some("my-host"))
+    assert(parsed.propertiesFile === Some("/tmp/celeborn.conf"))
+  }
+
+  test("parse with short port flag -p") {
+    val args = Array(
+      "--app-id",
+      "short-app",
+      "--master-endpoints",
+      "host:9097",
+      "-p",
+      "2048")
+    val parsed = LifecycleManagerDaemonArguments.parse(args)
+    assert(parsed.appId === "short-app")
+    assert(parsed.port === 2048)
+    assert(parsed.host.isEmpty)
+  }
+
+  test("-h is treated as help, not host") {
+    val args = Array("-h")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 0)
+    assert(ex.getMessage.contains("Usage"))
+  }
+
+  test("--help requests help with exit code 0") {
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(Array("--help"))
+    }
+    assert(ex.exitCode === 0)
+    assert(ex.getMessage.contains("Usage"))
+  }
+
+  test("unknown argument fails with exit code 1") {
+    val args = Array(
+      "--app-id",
+      "app",
+      "--master-endpoints",
+      "host:9097",
+      "--port",
+      "39099",
+      "--bogus")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 1)
+    assert(ex.getMessage.contains("Unknown argument: --bogus"))
+  }
+
+  test("missing --app-id fails with exit code 1") {
+    val args = Array("--master-endpoints", "host:9097", "--port", "39099")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 1)
+    assert(ex.getMessage.contains("--app-id is required"))
+  }
+
+  test("missing --master-endpoints fails with exit code 1") {
+    val args = Array("--app-id", "app", "--port", "39099")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 1)
+    assert(ex.getMessage.contains("--master-endpoints is required"))
+  }
+
+  test("missing --port fails with exit code 1") {
+    val args = Array("--app-id", "app", "--master-endpoints", "host:9097")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 1)
+    assert(ex.getMessage.contains("--port is required"))
+  }
+
+  test("port below 1024 fails with exit code 1") {
+    val args = Array(
+      "--app-id",
+      "app",
+      "--master-endpoints",
+      "host:9097",
+      "--port",
+      "1023")
+    val ex = intercept[ArgumentParseException] {
+      LifecycleManagerDaemonArguments.parse(args)
+    }
+    assert(ex.exitCode === 1)
+    assert(ex.getMessage.contains("must be >= 1024"))
+  }
+
+  test("applyArgsToConf sets master endpoints and shuffle manager port") {
+    val parsed = LifecycleManagerDaemonArguments(
+      appId = "app",
+      masterEndpoints = "host1:9097,host2:9097",
+      port = 39099,
+      host = None,
+      propertiesFile = None)
+    val conf = new CelebornConf()
+    LifecycleManagerDaemon.applyArgsToConf(parsed, conf)
+    assert(conf.get(CelebornConf.MASTER_ENDPOINTS.key) === 
"host1:9097,host2:9097")
+    assert(conf.get(CelebornConf.CLIENT_SHUFFLE_MANAGER_PORT.key) === "39099")
+  }
+
+  test("parse minimum valid port 1024") {
+    val args = Array(
+      "--app-id",
+      "app",
+      "--master-endpoints",
+      "host:9097",
+      "--port",
+      "1024")
+    val parsed = LifecycleManagerDaemonArguments.parse(args)
+    assert(parsed.port === 1024)
+  }
+
+  test("usage string contains all options") {
+    val usageText = LifecycleManagerDaemonArguments.usage
+    assert(usageText.contains("--app-id"))
+    assert(usageText.contains("--master-endpoints"))
+    assert(usageText.contains("--port"))
+    assert(usageText.contains("--host"))
+    assert(usageText.contains("--properties-file"))
+  }
+}
diff --git a/pom.xml b/pom.xml
index 04622055e..042cfa8e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,6 +38,7 @@
     <module>service</module>
     <module>master</module>
     <module>worker</module>
+    <module>lifecycle-manager</module>
     <module>cli</module>
   </modules>
 
diff --git a/project/CelebornBuild.scala b/project/CelebornBuild.scala
index e369ef80e..571134e30 100644
--- a/project/CelebornBuild.scala
+++ b/project/CelebornBuild.scala
@@ -471,6 +471,7 @@ object CelebornBuild extends sbt.internal.BuildDef {
       CelebornService.service,
       CelebornWorker.worker,
       CelebornMaster.master,
+      CelebornLifecycleManager.lifecycleManager,
       CelebornCli.cli
     ) ++ maybeSparkClientModules ++
       maybeFlinkClientModules ++
@@ -598,6 +599,19 @@ object Utils {
   }
 }
 
+object CelebornLifecycleManager {
+  lazy val lifecycleManager = Project("celeborn-lifecycle-manager", 
file("lifecycle-manager"))
+    .dependsOn(CelebornService.service % "test->test;compile->compile")
+    .dependsOn(CelebornClient.client % "test->test;compile->compile")
+    .dependsOn(CelebornCommon.common % "test->test;compile->compile")
+    .settings (
+      commonSettings,
+      libraryDependencies ++= Seq(
+        Dependencies.scalatestMockito % "test"
+      ) ++ commonUnitTestDependencies
+    )
+}
+
 object CelebornCli {
   lazy val cli = Project("celeborn-cli", file("cli"))
     .dependsOn(CelebornCommon.common % "test->test;compile->compile")
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 000000000..4a10facca
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1,7 @@
+target/
+Cargo.lock
+
+# Prebuilt celeborn-client shared libraries — built by cpp/, not tracked.
+resource/lib/**/*.so
+resource/lib/**/*.dylib
+resource/lib/**/*.dll
diff --git a/cpp/celeborn/CMakeLists.txt b/rust/Cargo.toml
similarity index 76%
copy from cpp/celeborn/CMakeLists.txt
copy to rust/Cargo.toml
index 1588488a7..8a966b4df 100644
--- a/cpp/celeborn/CMakeLists.txt
+++ b/rust/Cargo.toml
@@ -12,14 +12,10 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-add_subdirectory(proto)
-add_subdirectory(memory)
-add_subdirectory(utils)
-add_subdirectory(conf)
-add_subdirectory(protocol)
-add_subdirectory(network)
-add_subdirectory(client)
 
-if(CELEBORN_BUILD_TESTS)
-    add_subdirectory(tests)
-endif()
+[workspace]
+members = [
+    "celeborn-client-sys",
+    "celeborn-client",
+]
+resolver = "2"
diff --git a/cpp/celeborn/CMakeLists.txt b/rust/celeborn-client-sys/Cargo.toml
similarity index 76%
copy from cpp/celeborn/CMakeLists.txt
copy to rust/celeborn-client-sys/Cargo.toml
index 1588488a7..8e7a14993 100644
--- a/cpp/celeborn/CMakeLists.txt
+++ b/rust/celeborn-client-sys/Cargo.toml
@@ -12,14 +12,11 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-add_subdirectory(proto)
-add_subdirectory(memory)
-add_subdirectory(utils)
-add_subdirectory(conf)
-add_subdirectory(protocol)
-add_subdirectory(network)
-add_subdirectory(client)
 
-if(CELEBORN_BUILD_TESTS)
-    add_subdirectory(tests)
-endif()
+[package]
+name = "celeborn-client-sys"
+version = "0.1.0"
+edition = "2021"
+description = "Raw FFI bindings to libceleborn_client (single-dylib C ABI)"
+publish = false
+links = "celeborn_client"
diff --git a/rust/celeborn-client-sys/build.rs 
b/rust/celeborn-client-sys/build.rs
new file mode 100644
index 000000000..fecb321f3
--- /dev/null
+++ b/rust/celeborn-client-sys/build.rs
@@ -0,0 +1,186 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// Resolve the directory containing `libceleborn_client.{so,dylib}`.
+///
+/// Lookup order (first match wins):
+/// 1. `CELEBORN_CPP_PREFIX` env var → use `<prefix>/lib`. Intended for CI /
+///    custom installs.
+/// 2. In-repo prebuilt artifact at
+///    `rust/resource/lib/<target-triple>/libceleborn_client.{so,dylib}`.
+///    Drop the matching dylib in and `cargo build` picks it up with no
+///    extra environment variable.
+/// 3. Fall back to driving `cmake` against the in-repo `cpp/` source tree
+///    and installing into `$OUT_DIR/celeborn-cpp-install/lib`.
+fn resolve_lib_dir() -> PathBuf {
+    let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
+    let lib_filename = match target_os.as_str() {
+        "macos" => "libceleborn_client.dylib",
+        "linux" => "libceleborn_client.so",
+        other => panic!("unsupported target_os: {other} (only linux/macos 
supported)"),
+    };
+
+    // 1. Explicit override.
+    if let Ok(prefix) = std::env::var("CELEBORN_CPP_PREFIX") {
+        let lib_dir = PathBuf::from(&prefix).join("lib");
+        if !lib_dir.join(lib_filename).exists() {
+            panic!(
+                "CELEBORN_CPP_PREFIX={prefix} but {} does not exist.",
+                lib_dir.join(lib_filename).display()
+            );
+        }
+        eprintln!("cargo:warning=Using prebuilt Celeborn dylib from {prefix}");
+        return lib_dir;
+    }
+
+    // 2. In-repo prebuilt: rust/resource/lib/<target>/libceleborn_client.<ext>
+    let manifest_dir = 
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
+    let target = std::env::var("TARGET").unwrap();
+    let resource_lib_dir = manifest_dir
+        .join("../resource/lib")
+        .join(&target);
+    if resource_lib_dir.join(lib_filename).exists() {
+        eprintln!(
+            "cargo:warning=Using in-repo prebuilt Celeborn dylib at {}",
+            resource_lib_dir.display()
+        );
+        return resource_lib_dir
+            .canonicalize()
+            .expect("failed to canonicalize resource lib dir");
+    }
+
+    // 3. Fall back to cmake from source.
+    let cpp_source_dir = manifest_dir
+        .join("../../cpp")
+        .canonicalize()
+        .unwrap_or_else(|_| {
+            panic!(
+                "No prebuilt {lib_filename} found at {}, CELEBORN_CPP_PREFIX 
is unset, \
+                 and the in-repo cpp/ directory is not reachable from {}.",
+                resource_lib_dir.display(),
+                manifest_dir.display(),
+            )
+        });
+
+    eprintln!(
+        "cargo:warning=No prebuilt dylib at {}; building Celeborn C++ from 
source at {}",
+        resource_lib_dir.display(),
+        cpp_source_dir.display()
+    );
+
+    cmake_build_cpp(&cpp_source_dir).join("lib")
+}
+
+fn cmake_build_cpp(source_dir: &Path) -> PathBuf {
+    let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
+    let build_dir = out_dir.join("celeborn-cpp-build");
+    let install_dir = out_dir.join("celeborn-cpp-install");
+
+    std::fs::create_dir_all(&build_dir).expect("failed to create cmake build 
directory");
+    std::fs::create_dir_all(&install_dir).expect("failed to create cmake 
install directory");
+
+    let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
+
+    let mut configure_cmd = Command::new("cmake");
+    configure_cmd
+        .current_dir(&build_dir)
+        .arg(source_dir)
+        .arg(format!("-DCMAKE_INSTALL_PREFIX={}", install_dir.display()))
+        .arg("-DCMAKE_BUILD_TYPE=Release")
+        .arg("-DCELEBORN_BUILD_TESTS=OFF");
+
+    if target_os == "macos" {
+        let homebrew_prefix = std::env::var("HOMEBREW_PREFIX")
+            .unwrap_or_else(|_| "/opt/homebrew".to_string());
+        configure_cmd.arg(format!("-DCMAKE_PREFIX_PATH={homebrew_prefix}"));
+        configure_cmd.env(
+            "OPENSSL_ROOT_DIR",
+            format!("{homebrew_prefix}/opt/openssl@3"),
+        );
+    }
+
+    let configure_status = configure_cmd
+        .status()
+        .expect("failed to execute `cmake` – is cmake installed?");
+    if !configure_status.success() {
+        panic!("cmake configure step failed (exit code: {configure_status})");
+    }
+
+    let num_jobs = std::env::var("NUM_JOBS").unwrap_or_else(|_| 
num_cpus().to_string());
+    let build_status = Command::new("cmake")
+        .current_dir(&build_dir)
+        .args(["--build", "."])
+        .args(["--config", "Release"])
+        .args(["--parallel", &num_jobs])
+        .status()
+        .expect("failed to execute cmake --build");
+    if !build_status.success() {
+        panic!("cmake build step failed (exit code: {build_status})");
+    }
+
+    let install_status = Command::new("cmake")
+        .current_dir(&build_dir)
+        .args(["--install", "."])
+        .status()
+        .expect("failed to execute cmake --install");
+    if !install_status.success() {
+        panic!("cmake install step failed (exit code: {install_status})");
+    }
+
+    install_dir
+}
+
+fn num_cpus() -> usize {
+    std::thread::available_parallelism()
+        .map(|n| n.get())
+        .unwrap_or(4)
+}
+
+fn main() {
+    let lib_dir = resolve_lib_dir();
+    let lib_dir_str = lib_dir.display().to_string();
+
+    // Single aggregated dylib: libceleborn_client.{so,dylib} bundles every
+    // internal static lib (including the celeborn_ffi C ABI shim) and pulls
+    // third-party deps via NEEDED entries that resolve at runtime. The Rust
+    // crate touches no C++ headers and links nothing else.
+    println!("cargo:rustc-link-search=native={lib_dir_str}");
+    println!("cargo:rustc-link-lib=dylib=celeborn_client");
+
+    // Re-export lib_dir so the downstream `celeborn-client` crate can pick
+    // it up via DEP_CELEBORN_CLIENT_LIB_DIR and embed an rpath in its
+    // examples / tests / binaries. (`cargo:rustc-link-arg` does not
+    // propagate from a sys crate to dependent crates' artifacts.)
+    println!("cargo:metadata=lib_dir={lib_dir_str}");
+
+    println!("cargo:rerun-if-env-changed=CELEBORN_CPP_PREFIX");
+    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
+    println!("cargo:rerun-if-changed={manifest_dir}/../resource/lib");
+
+    // When we fall back to building cpp/ from source (option 3 in
+    // resolve_lib_dir), edits under cpp/ must trigger a rebuild of the
+    // dylib. Declare the dependency unconditionally so that toggling
+    // between prebuilt and from-source modes does not require touching
+    // the build script.
+    let cpp_dir = PathBuf::from(&manifest_dir).join("../../cpp");
+    if cpp_dir.exists() {
+        let cpp_dir_str = cpp_dir.display();
+        println!("cargo:rerun-if-changed={cpp_dir_str}/CMakeLists.txt");
+        println!("cargo:rerun-if-changed={cpp_dir_str}/celeborn");
+    }
+}
diff --git a/rust/celeborn-client-sys/src/lib.rs 
b/rust/celeborn-client-sys/src/lib.rs
new file mode 100644
index 000000000..811e67fa7
--- /dev/null
+++ b/rust/celeborn-client-sys/src/lib.rs
@@ -0,0 +1,158 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Raw C ABI bindings for `libceleborn_client.{so,dylib}`.
+//!
+//! All FFI is plain `extern "C"` — no `cxx`, no C++ headers, no template
+//! instantiations leak across the language boundary. The dylib is the sole
+//! external link dependency; folly / protobuf / glog / abseil all stay
+//! hidden inside it.
+
+#![allow(non_camel_case_types, non_upper_case_globals)]
+
+use std::os::raw::c_char;
+
+/// Opaque handle. Returned by [`celeborn_ffi_create_client`].
+///
+/// The handle is *intentionally never freed* — folly's
+/// `IOThreadPoolExecutor::join()` races with `TransportClient` teardown,
+/// so calling the C++ destructor produces SIGSEGV. Process-lifetime use.
+#[repr(C)]
+pub struct celeborn_ffi_handle {
+    _opaque: [u8; 0],
+    _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
+}
+
+/// Opaque handle to a single open partition reader. Returned by
+/// [`celeborn_ffi_open_partition_reader`]; released with
+/// [`celeborn_ffi_close_partition_reader`]. Holds a raw pointer into the
+/// owning client, so it must not outlive its [`celeborn_ffi_handle`].
+#[repr(C)]
+pub struct celeborn_ffi_partition_reader {
+    _opaque: [u8; 0],
+    _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
+}
+
+pub type celeborn_ffi_status = i32;
+pub const CELEBORN_FFI_OK: celeborn_ffi_status = 0;
+pub const CELEBORN_FFI_ERROR: celeborn_ffi_status = 1;
+
+extern "C" {
+    pub fn celeborn_ffi_free_error(err: *mut c_char);
+    pub fn celeborn_ffi_free_buffer(data: *mut u8);
+
+    pub fn celeborn_ffi_create_client(
+        app_id: *const c_char,
+        app_id_len: usize,
+        push_buffer_max_size: i32,
+        codec: *const c_char,
+        codec_len: usize,
+        err_out: *mut *mut c_char,
+    ) -> *mut celeborn_ffi_handle;
+
+    pub fn celeborn_ffi_setup_lifecycle_manager(
+        handle: *mut celeborn_ffi_handle,
+        host: *const c_char,
+        host_len: usize,
+        port: i32,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_shutdown(
+        handle: *mut celeborn_ffi_handle,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_push_data(
+        handle: *mut celeborn_ffi_handle,
+        shuffle_id: i32,
+        map_id: i32,
+        attempt_id: i32,
+        partition_id: i32,
+        data: *const u8,
+        data_len: usize,
+        num_mappers: i32,
+        num_partitions: i32,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_mapper_end(
+        handle: *mut celeborn_ffi_handle,
+        shuffle_id: i32,
+        map_id: i32,
+        attempt_id: i32,
+        num_mappers: i32,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_update_reducer_file_group(
+        handle: *mut celeborn_ffi_handle,
+        shuffle_id: i32,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_read_partition_full(
+        handle: *mut celeborn_ffi_handle,
+        shuffle_id: i32,
+        partition_id: i32,
+        attempt_number: i32,
+        start_map_index: i32,
+        end_map_index: i32,
+        data_out: *mut *mut u8,
+        len_out: *mut usize,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_open_partition_reader(
+        handle: *mut celeborn_ffi_handle,
+        shuffle_id: i32,
+        partition_id: i32,
+        attempt_number: i32,
+        start_map_index: i32,
+        end_map_index: i32,
+        reader_out: *mut *mut celeborn_ffi_partition_reader,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    /// Reads up to `buf_len` bytes into `buf`. Writes the number of bytes
+    /// actually read into `*bytes_read`; `0` indicates EOF (`std::io::Read`
+    /// semantics).
+    pub fn celeborn_ffi_read_partition_chunk(
+        reader: *mut celeborn_ffi_partition_reader,
+        buf: *mut u8,
+        buf_len: usize,
+        bytes_read: *mut usize,
+        err_out: *mut *mut c_char,
+    ) -> celeborn_ffi_status;
+
+    pub fn celeborn_ffi_close_partition_reader(reader: *mut 
celeborn_ffi_partition_reader);
+}
+
+/// Take ownership of a C error string and convert it to an owned `String`.
+///
+/// Releases the heap allocation via [`celeborn_ffi_free_error`].
+///
+/// # Safety
+/// `err` must be either null or a valid pointer returned by a
+/// `celeborn_ffi_*` function via its `err_out` parameter. After calling
+/// this, the pointer is dangling.
+pub unsafe fn take_error(err: *mut c_char) -> Option<String> {
+    if err.is_null() {
+        return None;
+    }
+    let msg = std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned();
+    celeborn_ffi_free_error(err);
+    Some(msg)
+}
diff --git a/.rat-excludes b/rust/celeborn-client/Cargo.toml
similarity index 62%
copy from .rat-excludes
copy to rust/celeborn-client/Cargo.toml
index 22c97119e..1427c650d 100644
--- a/.rat-excludes
+++ b/rust/celeborn-client/Cargo.toml
@@ -1,4 +1,3 @@
-#
 # Licensed to the Apache Software Foundation (ASF) under one or more
 # contributor license agreements.  See the NOTICE file distributed with
 # this work for additional information regarding copyright ownership.
@@ -13,28 +12,26 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-#
 
-**/.*/**
-**/*.json
-**/*.prefs
-**/*.log
-**/*.md
-**/*.iml
-**/*.svg
-**/target/**
-**/out/**
-**/spark-warehouse/**
-**/metastore_db/**
-**/licenses/LICENSE*
-**/licenses-binary/LICENSE*
-**/dependency-reduced-pom.xml
-**/scalastyle-output.xml
-NOTICE*
-assets/**
-build/apache-maven-*/**
-build/scala-*/**
-build/sbt-config/**
-**/benchmarks/**
-**/node_modules/**
-cpp/cmake/FindSodium.cmake
+[package]
+name = "celeborn-client"
+version = "0.1.0"
+edition = "2021"
+description = "Rust-friendly wrapper around Apache Celeborn C++ client FFI"
+publish = false
+
+[dependencies]
+celeborn-client-sys = { path = "../celeborn-client-sys" }
+thiserror = "1.0"
+log = "0.4"
+
+[dev-dependencies]
+env_logger = "0.11"
+
+[[example]]
+name = "data_sum_writer"
+path = "../examples/data_sum_writer.rs"
+
+[[example]]
+name = "data_sum_reader"
+path = "../examples/data_sum_reader.rs"
diff --git a/rust/celeborn-client/build.rs b/rust/celeborn-client/build.rs
new file mode 100644
index 000000000..4e41b4ede
--- /dev/null
+++ b/rust/celeborn-client/build.rs
@@ -0,0 +1,28 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Re-emit the dylib directory exported by `celeborn-client-sys` (via its
+// `links = "celeborn_client"` metadata) as an rpath on every artifact this
+// crate produces (examples, integration tests, downstream binaries).
+//
+// `cargo:rustc-link-arg=...` from a sys crate's build script does *not*
+// propagate to dependent crates' link lines, so the rpath has to be emitted
+// from the crate that owns those artifacts.
+fn main() {
+    if let Ok(lib_dir) = std::env::var("DEP_CELEBORN_CLIENT_LIB_DIR") {
+        println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}");
+    }
+    println!("cargo:rerun-if-env-changed=DEP_CELEBORN_CLIENT_LIB_DIR");
+}
diff --git a/rust/celeborn-client/src/lib.rs b/rust/celeborn-client/src/lib.rs
new file mode 100644
index 000000000..6b73cee79
--- /dev/null
+++ b/rust/celeborn-client/src/lib.rs
@@ -0,0 +1,478 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust-friendly wrapper around `celeborn-client-sys` (raw C ABI bindings
+//! to `libceleborn_client.{so,dylib}`).
+
+use std::marker::PhantomData;
+use std::os::raw::c_char;
+use std::ptr;
+
+use celeborn_client_sys as sys;
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("celeborn ffi error: {0}")]
+    Ffi(String),
+    #[error("invalid argument: {0}")]
+    InvalidArg(&'static str),
+    #[error("celeborn ffi returned a null handle")]
+    NullHandle,
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Build an [`Error::Ffi`] from a C error pointer (consumes the heap
+/// allocation). Returns a generic message if the pointer is null.
+unsafe fn ffi_error(err: *mut c_char) -> Error {
+    Error::Ffi(
+        sys::take_error(err)
+            .unwrap_or_else(|| "celeborn ffi returned no error 
message".to_string()),
+    )
+}
+
+/// Configuration for connecting to a Celeborn LifecycleManager.
+pub struct Config {
+    pub app_id: String,
+    /// Max push buffer size in bytes. 0 means use cpp default (64kB).
+    pub push_buffer_max_size: i32,
+    /// Compression codec: "NONE", "LZ4", or "ZSTD".
+    pub shuffle_compression_codec: String,
+}
+
+impl Config {
+    pub fn new(app_id: String) -> Self {
+        Self {
+            app_id,
+            push_buffer_max_size: 0,
+            shuffle_compression_codec: "NONE".to_string(),
+        }
+    }
+}
+
+/// A Rust-friendly Celeborn shuffle client backed by the C++ implementation.
+///
+/// The handle is intentionally leaked on `Drop` (after a best-effort
+/// shutdown) to work around a folly `EventBase` use-after-free that
+/// triggers when `TransportClient` is destroyed concurrently with
+/// `IOThreadPoolExecutor::join()`. Use `shutdown()` for explicit teardown.
+///
+/// # Per-process client assumption (IMPORTANT)
+///
+/// Because the underlying C++ `ClientImpl` is **never freed** — neither
+/// `shutdown()` nor `Drop` deletes it, and `connect()` leaks it entirely if
+/// `setup_lifecycle_manager` fails — each call to [`ShuffleClient::connect`]
+/// permanently leaks the client's config, endpoint, native client and its
+/// thread pool. This is acceptable for one-shot processes (the typical mapper
+/// / writer use case) but means a long-lived process that creates **many**
+/// clients will steadily leak memory and threads.
+///
+/// Treat this type as a **per-process singleton**: create one client for the
+/// lifetime of the process and reuse it (share via `Arc<ShuffleClient>` across
+/// threads) rather than repeatedly constructing and dropping clients.
+pub struct ShuffleClient {
+    handle: *mut sys::celeborn_ffi_handle,
+}
+
+// SAFETY: The underlying C++ `ShuffleClientImpl` owns its own thread pool and
+// synchronizes all shared state internally:
+//   - All shared maps (partition locations, push states, reducer file groups,
+//     mapper-end sets) are `folly`-backed concurrent maps guarded by
+//     `Synchronized`.
+//   - `registerShuffle` is serialized per-shuffleId with a dedicated mutex 
plus
+//     double-checked lookup.
+//   - A fresh compressor instance is created per `pushData` call, so no
+//     non-thread-safe compressor is shared across concurrent operations.
+// `pushData` and `readPartition` are therefore safe to invoke concurrently 
from
+// multiple threads on the same client, which is why every method below takes
+// `&self`. The handle is an opaque raw pointer with no Rust-level aliasing
+// concerns, so the client is both `Send` and `Sync` and can be shared via
+// `Arc<ShuffleClient>` for parallel push/read.
+//
+// Note: the `PartitionReader` returned by `open_partition` wraps a single
+// `CelebornInputStream` that holds mutable read state and is NOT thread-safe;
+// it keeps `&mut self` on its `Read` impl so a single reader cannot be read
+// concurrently from multiple threads. Opening multiple independent readers in
+// parallel is fine.
+unsafe impl Send for ShuffleClient {}
+unsafe impl Sync for ShuffleClient {}
+
+/// Pure validation of [`ShuffleClient::connect`] inputs, separated from any
+/// FFI so it can be unit-tested without a live cluster.
+fn validate_connect_args(config: &Config, lm_port: i32) -> Result<()> {
+    if config.app_id.is_empty() {
+        return Err(Error::InvalidArg("app_id is empty"));
+    }
+    if lm_port <= 0 {
+        return Err(Error::InvalidArg("lm_port must be > 0"));
+    }
+    let valid_codecs = ["NONE", "LZ4", "ZSTD"];
+    if !valid_codecs.contains(&config.shuffle_compression_codec.as_str()) {
+        return Err(Error::InvalidArg(
+            "shuffle_compression_codec must be NONE, LZ4, or ZSTD",
+        ));
+    }
+    Ok(())
+}
+
+impl ShuffleClient {
+    /// Connect to a running LifecycleManager at `lm_host:lm_port`.
+    pub fn connect(config: Config, lm_host: &str, lm_port: i32) -> 
Result<Self> {
+        validate_connect_args(&config, lm_port)?;
+
+        let mut err: *mut c_char = ptr::null_mut();
+        let handle = unsafe {
+            sys::celeborn_ffi_create_client(
+                config.app_id.as_ptr() as *const c_char,
+                config.app_id.len(),
+                config.push_buffer_max_size,
+                config.shuffle_compression_codec.as_ptr() as *const c_char,
+                config.shuffle_compression_codec.len(),
+                &mut err,
+            )
+        };
+        if handle.is_null() {
+            return Err(unsafe { ffi_error(err) });
+        }
+
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_setup_lifecycle_manager(
+                handle,
+                lm_host.as_ptr() as *const c_char,
+                lm_host.len(),
+                lm_port,
+                &mut err,
+            )
+        };
+        if status != sys::CELEBORN_FFI_OK {
+            // Intentional leak of `handle`: do not call any destructor that
+            // would tear down the folly EventBase state.
+            return Err(unsafe { ffi_error(err) });
+        }
+
+        Ok(Self { handle })
+    }
+
+    /// Push data for a specific partition.
+    ///
+    /// Takes `&self` so the client can be shared via `Arc<ShuffleClient>` and
+    /// pushed to concurrently from multiple threads; the underlying C++ client
+    /// synchronizes internally.
+    pub fn push_data(
+        &self,
+        shuffle_id: i32,
+        map_id: i32,
+        attempt_id: i32,
+        partition_id: i32,
+        data: &[u8],
+        num_mappers: i32,
+        num_partitions: i32,
+    ) -> Result<()> {
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_push_data(
+                self.handle,
+                shuffle_id,
+                map_id,
+                attempt_id,
+                partition_id,
+                data.as_ptr(),
+                data.len(),
+                num_mappers,
+                num_partitions,
+                &mut err,
+            )
+        };
+        if status == sys::CELEBORN_FFI_OK {
+            Ok(())
+        } else {
+            Err(unsafe { ffi_error(err) })
+        }
+    }
+
+    /// Signal that a mapper has finished writing all its partitions.
+    pub fn mapper_end(
+        &self,
+        shuffle_id: i32,
+        map_id: i32,
+        attempt_id: i32,
+        num_mappers: i32,
+    ) -> Result<()> {
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_mapper_end(
+                self.handle,
+                shuffle_id,
+                map_id,
+                attempt_id,
+                num_mappers,
+                &mut err,
+            )
+        };
+        if status == sys::CELEBORN_FFI_OK {
+            Ok(())
+        } else {
+            Err(unsafe { ffi_error(err) })
+        }
+    }
+
+    /// Update reducer file group metadata for a given shuffle.
+    pub fn update_reducer_file_group(&self, shuffle_id: i32) -> Result<()> {
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_update_reducer_file_group(self.handle, 
shuffle_id, &mut err)
+        };
+        if status == sys::CELEBORN_FFI_OK {
+            Ok(())
+        } else {
+            Err(unsafe { ffi_error(err) })
+        }
+    }
+
+    /// Read all data for a partition with full control over parameters.
+    ///
+    /// Takes `&self` so multiple partitions can be read in parallel from
+    /// different threads sharing one `Arc<ShuffleClient>`.
+    pub fn read_partition(
+        &self,
+        shuffle_id: i32,
+        partition_id: i32,
+        attempt_number: i32,
+        start_map_index: i32,
+        end_map_index: i32,
+    ) -> Result<Vec<u8>> {
+        let mut data_out: *mut u8 = ptr::null_mut();
+        let mut len_out: usize = 0;
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_read_partition_full(
+                self.handle,
+                shuffle_id,
+                partition_id,
+                attempt_number,
+                start_map_index,
+                end_map_index,
+                &mut data_out,
+                &mut len_out,
+                &mut err,
+            )
+        };
+        if status != sys::CELEBORN_FFI_OK {
+            return Err(unsafe { ffi_error(err) });
+        }
+        // Copy the C-allocated buffer into a Rust-owned Vec, then release
+        // the C buffer with the matching deallocator.
+        let out = unsafe { std::slice::from_raw_parts(data_out, 
len_out).to_vec() };
+        unsafe { sys::celeborn_ffi_free_buffer(data_out) };
+        Ok(out)
+    }
+
+    /// Convenience: read all map outputs for a partition.
+    #[inline]
+    pub fn read_partition_all(
+        &self,
+        shuffle_id: i32,
+        partition_id: i32,
+        num_mappers: i32,
+    ) -> Result<Vec<u8>> {
+        self.read_partition(shuffle_id, partition_id, 0, 0, num_mappers)
+    }
+
+    /// Open a streaming reader for a partition. The returned 
[`PartitionReader`]
+    /// implements [`std::io::Read`], so the caller can wrap it in a
+    /// [`std::io::BufReader`] and process bytes without materializing the
+    /// whole partition in memory.
+    ///
+    /// The reader borrows `&self`, so multiple partitions can be opened and
+    /// read in parallel from threads sharing one `Arc<ShuffleClient>`. Each
+    /// individual [`PartitionReader`] is single-threaded (its `Read` impl 
takes
+    /// `&mut self`), so a given reader must not be read from multiple threads
+    /// at once.
+    pub fn open_partition(
+        &self,
+        shuffle_id: i32,
+        partition_id: i32,
+        attempt_number: i32,
+        start_map_index: i32,
+        end_map_index: i32,
+    ) -> Result<PartitionReader<'_>> {
+        let mut reader_out: *mut sys::celeborn_ffi_partition_reader = 
ptr::null_mut();
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_open_partition_reader(
+                self.handle,
+                shuffle_id,
+                partition_id,
+                attempt_number,
+                start_map_index,
+                end_map_index,
+                &mut reader_out,
+                &mut err,
+            )
+        };
+        if status != sys::CELEBORN_FFI_OK {
+            return Err(unsafe { ffi_error(err) });
+        }
+        if reader_out.is_null() {
+            return Err(Error::NullHandle);
+        }
+        Ok(PartitionReader {
+            inner: reader_out,
+            _client: PhantomData,
+        })
+    }
+
+    /// Convenience: stream all map outputs for a partition.
+    #[inline]
+    pub fn open_partition_all(
+        &self,
+        shuffle_id: i32,
+        partition_id: i32,
+        num_mappers: i32,
+    ) -> Result<PartitionReader<'_>> {
+        self.open_partition(shuffle_id, partition_id, 0, 0, num_mappers)
+    }
+
+    /// Explicitly shut down the client. Preferred over relying on Drop.
+    ///
+    /// After calling `celeborn_ffi_shutdown`, the underlying C++ handle is
+    /// intentionally leaked (see the type-level docs).
+    pub fn shutdown(mut self) -> Result<()> {
+        let mut err: *mut c_char = ptr::null_mut();
+        let status =
+            unsafe { sys::celeborn_ffi_shutdown(self.handle, &mut err) };
+        // Null the handle so Drop does not call shutdown a second time.
+        self.handle = ptr::null_mut();
+        if status == sys::CELEBORN_FFI_OK {
+            Ok(())
+        } else {
+            Err(unsafe { ffi_error(err) })
+        }
+    }
+}
+
+impl Drop for ShuffleClient {
+    fn drop(&mut self) {
+        if self.handle.is_null() {
+            return;
+        }
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe { sys::celeborn_ffi_shutdown(self.handle, &mut 
err) };
+        if status != sys::CELEBORN_FFI_OK {
+            let msg = unsafe { sys::take_error(err) }
+                .unwrap_or_else(|| "no error message".to_string());
+            log::error!(
+                "celeborn_ffi_shutdown failed during Drop: {msg}; \
+                 caller should explicitly call ShuffleClient::shutdown() 
before drop"
+            );
+        }
+        // Intentional leak — no celeborn_ffi_destroy call. See type docs.
+        self.handle = ptr::null_mut();
+    }
+}
+
+/// Streaming reader for a single partition, returned by
+/// [`ShuffleClient::open_partition`]. Implements [`std::io::Read`]; wrap in
+/// a [`std::io::BufReader`] to avoid one FFI call per byte.
+pub struct PartitionReader<'client> {
+    inner: *mut sys::celeborn_ffi_partition_reader,
+    _client: PhantomData<&'client ShuffleClient>,
+}
+
+// SAFETY: the underlying C++ `CelebornInputStream` is owned exclusively by 
this
+// reader and only touched through `&mut self` (see the `Read` impl), so it can
+// be moved to another thread. It is deliberately NOT `Sync`: a single reader
+// holds mutable read state and must not be read from multiple threads at once.
+unsafe impl<'client> Send for PartitionReader<'client> {}
+
+impl<'client> std::io::Read for PartitionReader<'client> {
+    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
+        let mut bytes_read: usize = 0;
+        let mut err: *mut c_char = ptr::null_mut();
+        let status = unsafe {
+            sys::celeborn_ffi_read_partition_chunk(
+                self.inner,
+                buf.as_mut_ptr(),
+                buf.len(),
+                &mut bytes_read,
+                &mut err,
+            )
+        };
+        if status != sys::CELEBORN_FFI_OK {
+            let msg = unsafe { sys::take_error(err) }
+                .unwrap_or_else(|| "no error message".to_string());
+            return Err(std::io::Error::other(msg));
+        }
+        Ok(bytes_read)
+    }
+}
+
+impl<'client> Drop for PartitionReader<'client> {
+    fn drop(&mut self) {
+        if !self.inner.is_null() {
+            unsafe { sys::celeborn_ffi_close_partition_reader(self.inner) };
+            self.inner = ptr::null_mut();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn config_with(app_id: &str, codec: &str) -> Config {
+        let mut config = Config::new(app_id.to_string());
+        config.shuffle_compression_codec = codec.to_string();
+        config
+    }
+
+    #[test]
+    fn validate_accepts_supported_codecs() {
+        for codec in ["NONE", "LZ4", "ZSTD"] {
+            let config = config_with("app", codec);
+            assert!(validate_connect_args(&config, 39099).is_ok());
+        }
+    }
+
+    #[test]
+    fn validate_rejects_empty_app_id() {
+        let config = config_with("", "NONE");
+        let err = validate_connect_args(&config, 39099).unwrap_err();
+        assert!(matches!(err, Error::InvalidArg("app_id is empty")));
+    }
+
+    #[test]
+    fn validate_rejects_non_positive_port() {
+        let config = config_with("app", "NONE");
+        assert!(matches!(
+            validate_connect_args(&config, 0).unwrap_err(),
+            Error::InvalidArg("lm_port must be > 0")
+        ));
+        assert!(matches!(
+            validate_connect_args(&config, -1).unwrap_err(),
+            Error::InvalidArg("lm_port must be > 0")
+        ));
+    }
+
+    #[test]
+    fn validate_rejects_unknown_codec() {
+        let config = config_with("app", "GZIP");
+        assert!(matches!(
+            validate_connect_args(&config, 39099).unwrap_err(),
+            Error::InvalidArg(_)
+        ));
+    }
+}
diff --git a/rust/examples/data_sum_reader.rs b/rust/examples/data_sum_reader.rs
new file mode 100644
index 000000000..18c34a26f
--- /dev/null
+++ b/rust/examples/data_sum_reader.rs
@@ -0,0 +1,137 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust equivalent of cpp/celeborn/tests/DataSumWithReaderClient.cpp
+//! Usage: data_sum_reader <lm_host> <lm_port> <app_id> <shuffle_id> 
<attempt_id>
+//!        <num_mappers> <num_partitions> <result_file> <compress_codec>
+//!
+//! Concurrency: a single `Arc<ShuffleClient>` is shared across 
`num_partitions`
+//! threads. Each thread opens and drains its own partition reader in parallel 
—
+//! exercising the `&self` `open_partition` path. Each `PartitionReader` itself
+//! stays single-threaded (its `Read` impl takes `&mut self`).
+//!
+//! Set RUST_LOG=info for diagnostic output.
+
+use celeborn_client::{Config, ShuffleClient};
+use std::env;
+use std::fs::File;
+use std::io::{BufReader, Read, Write};
+use std::sync::Arc;
+use std::thread;
+
+fn main() {
+    env_logger::init();
+
+    let args: Vec<String> = env::args().collect();
+    if args.len() != 10 {
+        eprintln!(
+            "Usage: {} <lm_host> <lm_port> <app_id> <shuffle_id> <attempt_id> \
+             <num_mappers> <num_partitions> <result_file> <compress_codec>",
+            args[0]
+        );
+        std::process::exit(1);
+    }
+
+    let lm_host = &args[1];
+    let lm_port: i32 = args[2].parse().expect("lm_port must be an integer");
+    let app_id = args[3].clone();
+    let shuffle_id: i32 = args[4].parse().expect("shuffle_id must be an 
integer");
+    let attempt_id: i32 = args[5].parse().expect("attempt_id must be an 
integer");
+    let num_mappers: i32 = args[6].parse().expect("num_mappers must be an 
integer");
+    let num_partitions: i32 = args[7].parse().expect("num_partitions must be 
an integer");
+    let result_file = &args[8];
+    let compress_codec = args[9].clone();
+
+    println!(
+        "lm_host={lm_host}, lm_port={lm_port}, app_id={app_id}, \
+         shuffle_id={shuffle_id}, attempt_id={attempt_id}, \
+         num_mappers={num_mappers}, num_partitions={num_partitions}, \
+         result_file={result_file}, compress_codec={compress_codec}"
+    );
+
+    let mut config = Config::new(app_id);
+    config.shuffle_compression_codec = compress_codec;
+
+    let client =
+        Arc::new(ShuffleClient::connect(config, lm_host, 
lm_port).expect("Failed to connect to LM"));
+
+    client
+        .update_reducer_file_group(shuffle_id)
+        .expect("update_reducer_file_group failed");
+
+    // Spawn one thread per partition. All threads share a single
+    // `Arc<ShuffleClient>` and open their readers concurrently via the `&self`
+    // API. Each thread returns its (partition_id, sum, data_count).
+    let handles: Vec<_> = (0..num_partitions)
+        .map(|partition_id| {
+            let client = Arc::clone(&client);
+            thread::spawn(move || {
+                let reader = client
+                    .open_partition(shuffle_id, partition_id, attempt_id, 0, 
num_mappers)
+                    .expect("open_partition failed");
+                let mut buf_reader = BufReader::with_capacity(64 * 1024, 
reader);
+
+                let mut sum: i64 = 0;
+                let mut current_number: i64 = 0;
+                let mut data_count: usize = 0;
+                let mut byte = [0u8; 1];
+
+                loop {
+                    let n = buf_reader.read(&mut byte).expect("read failed");
+                    if n == 0 {
+                        break;
+                    }
+                    let c = byte[0] as char;
+                    match c {
+                        '-' => {
+                            sum += current_number;
+                            current_number = 0;
+                            data_count += 1;
+                        }
+                        '+' => {}
+                        '0'..='9' => {
+                            current_number = current_number * 10 + (c as i64 - 
'0' as i64);
+                        }
+                        _ => {
+                            panic!("Unexpected character in partition data: 
'{c}'");
+                        }
+                    }
+                }
+                // Add the last number (data after last '-')
+                sum += current_number;
+
+                (partition_id, sum, data_count)
+            })
+        })
+        .collect();
+
+    let mut result = vec![0i64; num_partitions as usize];
+    for handle in handles {
+        let (partition_id, sum, data_count) = handle.join().expect("reader 
thread panicked");
+        result[partition_id as usize] = sum;
+        println!("partition {partition_id} sum result = {sum}, dataCnt = 
{data_count}");
+    }
+
+    let mut file = File::create(result_file).expect("Failed to create result 
file");
+    for sum in &result {
+        writeln!(file, "{sum}").expect("Failed to write result");
+    }
+
+    // All reader threads have joined, so this is the only remaining Arc 
handle.
+    let client = Arc::try_unwrap(client)
+        .unwrap_or_else(|_| panic!("ShuffleClient still has other Arc 
references"));
+    client.shutdown().expect("shutdown failed");
+    println!("Reader completed successfully.");
+}
diff --git a/rust/examples/data_sum_writer.rs b/rust/examples/data_sum_writer.rs
new file mode 100644
index 000000000..5d7780bd5
--- /dev/null
+++ b/rust/examples/data_sum_writer.rs
@@ -0,0 +1,154 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust equivalent of cpp/celeborn/tests/DataSumWithWriterClient.cpp
+//! Usage: data_sum_writer <lm_host> <lm_port> <app_id> <shuffle_id> 
<attempt_id>
+//!        <num_mappers> <num_partitions> <result_file> <compress_codec>
+//!
+//! Concurrency: a single `Arc<ShuffleClient>` is shared across `num_mappers`
+//! threads. Each thread owns one map task, pushes all of its partitions, and
+//! issues `mapper_end`, all in parallel — exercising the `&self` push path 
that
+//! makes the client safe to share for concurrent writes.
+//!
+//! Requires env_logger initialized for diagnostic output from celeborn-client 
Drop path.
+//! Set RUST_LOG=info (or debug) for verbose output.
+
+use celeborn_client::{Config, ShuffleClient};
+use std::env;
+use std::fs::File;
+use std::io::Write;
+use std::sync::atomic::{AtomicI64, Ordering};
+use std::sync::Arc;
+use std::thread;
+
+fn main() {
+    env_logger::init();
+
+    let args: Vec<String> = env::args().collect();
+    if args.len() != 10 {
+        eprintln!(
+            "Usage: {} <lm_host> <lm_port> <app_id> <shuffle_id> <attempt_id> \
+             <num_mappers> <num_partitions> <result_file> <compress_codec>",
+            args[0]
+        );
+        std::process::exit(1);
+    }
+
+    let lm_host = &args[1];
+    let lm_port: i32 = args[2].parse().expect("lm_port must be an integer");
+    let app_id = args[3].clone();
+    let shuffle_id: i32 = args[4].parse().expect("shuffle_id must be an 
integer");
+    let attempt_id: i32 = args[5].parse().expect("attempt_id must be an 
integer");
+    let num_mappers: i32 = args[6].parse().expect("num_mappers must be an 
integer");
+    let num_partitions: i32 = args[7].parse().expect("num_partitions must be 
an integer");
+    let result_file = &args[8];
+    let compress_codec = args[9].clone();
+
+    println!(
+        "lm_host={lm_host}, lm_port={lm_port}, app_id={app_id}, \
+         shuffle_id={shuffle_id}, attempt_id={attempt_id}, \
+         num_mappers={num_mappers}, num_partitions={num_partitions}, \
+         result_file={result_file}, compress_codec={compress_codec}"
+    );
+
+    let mut config = Config::new(app_id);
+    config.shuffle_compression_codec = compress_codec;
+
+    let client =
+        Arc::new(ShuffleClient::connect(config, lm_host, 
lm_port).expect("Failed to connect to LM"));
+
+    let max_data: i64 = 1_000_000;
+    let num_data: usize = 1000;
+
+    // Shared per-partition sums; multiple mapper threads accumulate 
concurrently.
+    let result: Arc<Vec<AtomicI64>> =
+        Arc::new((0..num_partitions).map(|_| AtomicI64::new(0)).collect());
+
+    // Spawn one thread per map task. All threads share a single
+    // `Arc<ShuffleClient>` and push concurrently via the `&self` API.
+    let handles: Vec<_> = (0..num_mappers)
+        .map(|map_id| {
+            let client = Arc::clone(&client);
+            let result = Arc::clone(&result);
+            thread::spawn(move || {
+                // Seed the RNG per map task so every mapper thread produces a
+                // DISTINCT byte stream. A shared/fixed seed would make all
+                // threads push identical data, which masks data races in the
+                // concurrent `&self` push path this example is meant to 
stress.
+                let mut rng_state: u64 = 0x9E37_79B9_7F4A_7C15 ^ (map_id as 
u64).wrapping_add(1);
+                for partition_id in 0..num_partitions {
+                    let mut partition_data = String::new();
+                    let mut partition_sum: i64 = 0;
+                    for _ in 0..num_data {
+                        let data = rand_simple(&mut rng_state, max_data);
+                        partition_sum += data;
+                        partition_data.push('-');
+                        partition_data.push_str(&data.to_string());
+                    }
+                    result[partition_id as usize].fetch_add(partition_sum, 
Ordering::Relaxed);
+
+                    client
+                        .push_data(
+                            shuffle_id,
+                            map_id,
+                            attempt_id,
+                            partition_id,
+                            partition_data.as_bytes(),
+                            num_mappers,
+                            num_partitions,
+                        )
+                        .expect("push_data failed");
+                }
+                client
+                    .mapper_end(shuffle_id, map_id, attempt_id, num_mappers)
+                    .expect("mapper_end failed");
+            })
+        })
+        .collect();
+
+    for handle in handles {
+        handle.join().expect("mapper thread panicked");
+    }
+
+    let result: Vec<i64> = result.iter().map(|v| 
v.load(Ordering::Relaxed)).collect();
+    for (partition_id, sum) in result.iter().enumerate() {
+        println!("partition {partition_id} sum result = {sum}");
+    }
+
+    let mut file = File::create(result_file).expect("Failed to create result 
file");
+    for sum in &result {
+        writeln!(file, "{sum}").expect("Failed to write result");
+    }
+
+    // All mapper threads have joined, so this is the only remaining Arc 
handle.
+    let client = Arc::try_unwrap(client)
+        .unwrap_or_else(|_| panic!("ShuffleClient still has other Arc 
references"));
+    client.shutdown().expect("shutdown failed");
+    println!("Writer completed successfully.");
+}
+
+/// Simple xorshift pseudo-random number generator. The caller owns the
+/// `state`, so each mapper thread can seed it independently and generate a
+/// distinct byte stream — necessary for the concurrency stress this example
+/// performs (a shared/fixed seed would make all threads emit identical data
+/// and hide races in the `&self` push path).
+fn rand_simple(state: &mut u64, max_val: i64) -> i64 {
+    let mut x = *state;
+    x ^= x << 13;
+    x ^= x >> 7;
+    x ^= x << 17;
+    *state = x;
+    ((x >> 1) as i64) % max_val
+}
diff --git a/rust/resource/lib/README.md b/rust/resource/lib/README.md
new file mode 100644
index 000000000..503c5d6bf
--- /dev/null
+++ b/rust/resource/lib/README.md
@@ -0,0 +1,51 @@
+# Prebuilt Celeborn native artifacts
+
+`celeborn-client-sys/build.rs` looks up the aggregated dylib here before
+falling back to a from-source `cmake` build, so dropping the right file
+in is all it takes for `cargo build` to work without any environment
+variable.
+
+## Layout
+
+```
+resource/lib/
+  <target-triple>/
+    libceleborn_client.dylib    # macOS
+    libceleborn_client.so       # Linux
+```
+
+`<target-triple>` is the Cargo target identifier — the same string Cargo
+prints from `rustc -vV` (`host:` line) or accepts in `cargo build --target`:
+
+| Platform         | Triple                       | Filename                  |
+|------------------|------------------------------|---------------------------|
+| macOS Apple Silicon | `aarch64-apple-darwin`    | `libceleborn_client.dylib`|
+| macOS Intel      | `x86_64-apple-darwin`        | `libceleborn_client.dylib`|
+| Linux x86_64     | `x86_64-unknown-linux-gnu`   | `libceleborn_client.so`   |
+| Linux aarch64    | `aarch64-unknown-linux-gnu`  | `libceleborn_client.so`   |
+
+## How to refresh the artifact
+
+Build `cpp/` once, then copy the install output:
+
+```bash
+cd cpp && mkdir -p build && cd build
+OPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@3 cmake .. \
+  -DCMAKE_BUILD_TYPE=Release -DCELEBORN_BUILD_TESTS=OFF \
+  -DCMAKE_PREFIX_PATH=/opt/homebrew \
+  -DCMAKE_INSTALL_PREFIX=$PWD/_install
+cmake --build . --parallel && cmake --install .
+
+TRIPLE=$(rustc -vV | sed -n 's|host: ||p')
+mkdir -p ../../rust/resource/lib/$TRIPLE
+cp _install/lib/libceleborn_client.* ../../rust/resource/lib/$TRIPLE/
+```
+
+After that, `cargo build` from `rust/` finds the dylib without
+`CELEBORN_CPP_PREFIX`.
+
+## Lookup precedence (in `celeborn-client-sys/build.rs`)
+
+1. `CELEBORN_CPP_PREFIX` env var → `<prefix>/lib/libceleborn_client.*`
+2. `rust/resource/lib/<target-triple>/libceleborn_client.*` (this directory)
+3. From-source `cmake` build of `cpp/` into `$OUT_DIR`
diff --git a/sbin/start-lifecycle-manager.sh b/sbin/start-lifecycle-manager.sh
new file mode 100755
index 000000000..0560ccff3
--- /dev/null
+++ b/sbin/start-lifecycle-manager.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Starts a standalone Celeborn LifecycleManager daemon in background.
+# The RPC port is randomly selected from 30000~50000 if --port is not 
specified.
+# After successful start, the port is exported as CELEBORN_LM_PORT.
+
+set -euo pipefail
+
+if [ -z "${CELEBORN_HOME:-}" ]; then
+  export CELEBORN_HOME="$(cd "$(dirname "$0")/.."; pwd)"
+fi
+
+usage() {
+  cat <<EOF
+Usage: $0 --app-id <id> --master-endpoints <ep1,ep2,...> [--port <port>] 
[--host <host>] [--properties-file <file>]
+
+  --app-id              REQUIRED  unique application id
+  --master-endpoints    REQUIRED  comma-separated host:port of Celeborn Masters
+  --port                OPTIONAL  fixed RPC port to bind (default: random 
available port in 30000~50000)
+  --host                OPTIONAL  bind host (default: hostname)
+  --properties-file     OPTIONAL  path to celeborn-defaults.conf
+EOF
+  exit 1
+}
+
+# Probe whether `port` is currently bound on ANY local interface.
+# Returns 0 when the port appears free, non-zero when something is listening.
+# Prefer lsof / ss / netstat (cover every interface, IPv4 and IPv6) and only
+# fall back to /dev/tcp loopback probing — the latter misses listeners bound
+# to a specific non-loopback interface, which would let us hand out a port
+# the daemon cannot actually bind.
+port_is_free() {
+  local port="$1"
+  if command -v lsof >/dev/null 2>&1; then
+    ! lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
+    return
+  fi
+  if command -v ss >/dev/null 2>&1; then
+    ! ss -ltn "sport = :${port}" 2>/dev/null | awk 'NR>1 {found=1} END {exit 
!found}'
+    return
+  fi
+  if command -v netstat >/dev/null 2>&1; then
+    ! netstat -an 2>/dev/null | awk -v p=":${port}$" '$1 ~ /^tcp/ && $0 ~ 
/LISTEN/ && $4 ~ p {found=1} END {exit !found}'
+    return
+  fi
+  # Last-resort loopback probe (incomplete — see note above).
+  ! (echo >/dev/tcp/127.0.0.1/"${port}") 2>/dev/null
+}
+
+# Find a random available port in range [30000, 50000]
+find_available_port() {
+  local port
+  local max_attempts=100
+  local attempt=0
+  while [ "$attempt" -lt "$max_attempts" ]; do
+    port=$(( RANDOM % 20001 + 30000 ))
+    if port_is_free "$port"; then
+      echo "$port"
+      return 0
+    fi
+    attempt=$(( attempt + 1 ))
+  done
+  echo "Error: failed to find an available port in 30000~50000 after 
$max_attempts attempts." >&2
+  return 1
+}
+
+# Pre-check: required args must be present
+have_app_id=0; have_master=0; have_port=0
+for ((i=1; i<=$#; i++)); do
+  case "${!i}" in
+    --app-id) have_app_id=1 ;;
+    --master-endpoints) have_master=1 ;;
+    --port) have_port=1 ;;
+  esac
+done
+
+if [ "$have_app_id" -eq 0 ] || [ "$have_master" -eq 0 ]; then
+  echo "Error: --app-id and --master-endpoints are required." >&2
+  usage
+fi
+
+# If --port is not specified, find a random available port and append it to 
args
+if [ "$have_port" -eq 0 ]; then
+  CELEBORN_LM_PORT=$(find_available_port)
+  echo "Auto-selected available port: ${CELEBORN_LM_PORT}"
+  set -- "$@" --port "${CELEBORN_LM_PORT}"
+else
+  # Extract the port value from args
+  for ((i=1; i<=$#; i++)); do
+    if [ "${!i}" = "--port" ]; then
+      j=$(( i + 1 ))
+      CELEBORN_LM_PORT="${!j}"
+      break
+    fi
+  done
+fi
+
+export CELEBORN_LM_PORT
+
+# Load environment (JAVA_HOME, CELEBORN_CONF_DIR, etc.)
+# Temporarily disable nounset because load-celeborn-env.sh checks unset vars
+if [ -f "${CELEBORN_HOME}/sbin/load-celeborn-env.sh" ]; then
+  set +u
+  # shellcheck source=/dev/null
+  . "${CELEBORN_HOME}/sbin/load-celeborn-env.sh"
+  set -u
+fi
+
+CELEBORN_CONF_DIR="${CELEBORN_CONF_DIR:-${CELEBORN_HOME}/conf}"
+
+# Determine Java
+if [ -n "${JAVA_HOME:-}" ]; then
+  JAVA="${JAVA_HOME}/bin/java"
+else
+  JAVA="java"
+fi
+
+# Build classpath: conf + service jars + client jars + common jars
+CLASSPATH="${CELEBORN_CONF_DIR}"
+for dir in \
+  "${CELEBORN_HOME}/lifecycle-manager/target/"*.jar \
+  "${CELEBORN_HOME}/service/target/"*.jar \
+  "${CELEBORN_HOME}/client/target/"*.jar \
+  "${CELEBORN_HOME}/common/target/"*.jar \
+  "${CELEBORN_HOME}/spi/target/"*.jar \
+  "${CELEBORN_HOME}/jars/"*.jar \
+  "${CELEBORN_HOME}/lifecycle-manager-jars/"*.jar \
+  "${CELEBORN_HOME}/service-jars/"*.jar \
+  "${CELEBORN_HOME}/client-jars/"*.jar; do
+  if [ -e "$dir" ]; then
+    CLASSPATH="${CLASSPATH}:${dir}"
+  fi
+done
+
+# JVM options
+CELEBORN_JAVA_OPTS="${CELEBORN_JAVA_OPTS:-}"
+if [ -f "${CELEBORN_CONF_DIR}/log4j2.xml" ]; then
+  CELEBORN_JAVA_OPTS="${CELEBORN_JAVA_OPTS} 
-Dlog4j2.configurationFile=file:${CELEBORN_CONF_DIR}/log4j2.xml"
+fi
+
+MAIN_CLASS="org.apache.celeborn.server.lifecyclemanager.LifecycleManagerDaemon"
+
+# --- Background execution ---
+LOG_DIR="${CELEBORN_HOME}/logs"
+mkdir -p "${LOG_DIR}"
+LOG_FILE="${LOG_DIR}/lifecyclemanager-${CELEBORN_LM_PORT}.out"
+PID_FILE="${LOG_DIR}/lifecyclemanager-${CELEBORN_LM_PORT}.pid"
+
+nohup "${JAVA}" -cp "${CLASSPATH}" ${CELEBORN_JAVA_OPTS} "${MAIN_CLASS}" "$@" \
+  > "${LOG_FILE}" 2>&1 &
+
+CELEBORN_LM_PID=$!
+echo "${CELEBORN_LM_PID}" > "${PID_FILE}"
+
+# Wait until the daemon either binds its RPC port or dies. A fixed `sleep`
+# would report success while the JVM is still starting and the port is not yet
+# bound; poll the port (reusing port_is_free) so we only declare success once
+# the RPC endpoint is actually accepting connections.
+startup_ok=0
+max_wait_secs=30
+waited=0
+while [ "$waited" -lt "$max_wait_secs" ]; do
+  if ! kill -0 "${CELEBORN_LM_PID}" 2>/dev/null; then
+    # Process exited during startup.
+    break
+  fi
+  if ! port_is_free "${CELEBORN_LM_PORT}"; then
+    # Something (our daemon) is now listening on the port.
+    startup_ok=1
+    break
+  fi
+  sleep 1
+  waited=$(( waited + 1 ))
+done
+
+if [ "$startup_ok" -eq 1 ]; then
+  export CELEBORN_LM_PORT
+  export CELEBORN_LM_PID
+
+  # Write port to a env file so other scripts can source it
+  ENV_FILE="${LOG_DIR}/lifecyclemanager-${CELEBORN_LM_PID}.env"
+  cat > "${ENV_FILE}" <<ENVEOF
+export CELEBORN_LM_PORT=${CELEBORN_LM_PORT}
+export CELEBORN_LM_PID=${CELEBORN_LM_PID}
+ENVEOF
+
+  echo "============================================="
+  echo " LifecycleManager started successfully!"
+  echo "   PID  : ${CELEBORN_LM_PID}"
+  echo "   PORT : ${CELEBORN_LM_PORT}"
+  echo "   LOG  : ${LOG_FILE}"
+  echo "   ENV  : ${ENV_FILE}"
+  echo ""
+  echo " To load the port in another shell:"
+  echo "   source ${ENV_FILE}"
+  echo "============================================="
+else
+  echo "Error: LifecycleManager failed to start. Check log: ${LOG_FILE}" >&2
+  cat "${LOG_FILE}" >&2
+  rm -f "${PID_FILE}"
+  exit 1
+fi
diff --git a/sbin/stop-lifecycle-manager.sh b/sbin/stop-lifecycle-manager.sh
new file mode 100755
index 000000000..e2a061cc9
--- /dev/null
+++ b/sbin/stop-lifecycle-manager.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Stops a Celeborn LifecycleManager daemon by PID file or port.
+# Usage:
+#   stop-lifecycle-manager.sh --port <port>    Stop the instance bound to 
<port>
+#   stop-lifecycle-manager.sh --all            Stop all LifecycleManager 
instances
+
+set -euo pipefail
+
+if [ -z "${CELEBORN_HOME:-}" ]; then
+  export CELEBORN_HOME="$(cd "$(dirname "$0")/.."; pwd)"
+fi
+
+LOG_DIR="${CELEBORN_HOME}/logs"
+GRACEFUL_TIMEOUT=10
+
+usage() {
+  cat <<EOF
+Usage: $0 --port <port> | --all
+
+  --port <port>   Stop the LifecycleManager instance running on the specified 
port
+  --all           Stop all LifecycleManager instances managed by this script
+EOF
+  exit 1
+}
+
+# Stop a single instance by its PID file.
+# Returns:
+#   0  successfully stopped (or nothing to do — empty/stale PID file cleaned 
up)
+#   1  PID file missing entirely (caller decides whether to warn)
+#   2  the process is alive but we failed to kill it
+stop_by_pid_file() {
+  local pid_file="$1"
+  local pid
+
+  if [ ! -f "${pid_file}" ]; then
+    echo "PID file not found: ${pid_file}" >&2
+    return 1
+  fi
+
+  pid=$(cat "${pid_file}")
+  if [ -z "${pid}" ]; then
+    echo "PID file is empty: ${pid_file}, removing." >&2
+    rm -f "${pid_file}"
+    return 0
+  fi
+
+  if kill -0 "${pid}" 2>/dev/null; then
+    echo "Stopping LifecycleManager (PID: ${pid}) with SIGTERM ..."
+    if ! kill -TERM "${pid}" 2>/dev/null; then
+      echo "Failed to send SIGTERM to PID ${pid}." >&2
+      return 2
+    fi
+
+    # Wait for graceful shutdown
+    local waited=0
+    while [ "${waited}" -lt "${GRACEFUL_TIMEOUT}" ]; do
+      if ! kill -0 "${pid}" 2>/dev/null; then
+        echo "LifecycleManager (PID: ${pid}) stopped."
+        rm -f "${pid_file}"
+        # Clean up the env file
+        rm -f "${LOG_DIR}/lifecyclemanager-${pid}.env"
+        return 0
+      fi
+      sleep 1
+      waited=$(( waited + 1 ))
+    done
+
+    # Force kill if still alive
+    echo "LifecycleManager (PID: ${pid}) did not stop after 
${GRACEFUL_TIMEOUT}s, sending SIGKILL ..."
+    kill -9 "${pid}" 2>/dev/null || true
+    sleep 1
+    if kill -0 "${pid}" 2>/dev/null; then
+      echo "LifecycleManager (PID: ${pid}) is still alive after SIGKILL." >&2
+      return 2
+    fi
+    rm -f "${pid_file}"
+    rm -f "${LOG_DIR}/lifecyclemanager-${pid}.env"
+    echo "LifecycleManager (PID: ${pid}) killed."
+    return 0
+  else
+    echo "LifecycleManager (PID: ${pid}) is not running. Cleaning up stale PID 
file."
+    rm -f "${pid_file}"
+    rm -f "${LOG_DIR}/lifecyclemanager-${pid}.env"
+    return 0
+  fi
+}
+
+# Parse arguments
+if [ $# -eq 0 ]; then
+  usage
+fi
+
+MODE=""
+TARGET_PORT=""
+
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --port)
+      MODE="port"
+      TARGET_PORT="${2:-}"
+      if [ -z "${TARGET_PORT}" ]; then
+        echo "Error: --port requires a port number." >&2
+        usage
+      fi
+      shift 2
+      ;;
+    --all)
+      MODE="all"
+      shift
+      ;;
+    *)
+      echo "Unknown option: $1" >&2
+      usage
+      ;;
+  esac
+done
+
+if [ -z "${MODE}" ]; then
+  usage
+fi
+
+case "${MODE}" in
+  port)
+    PID_FILE="${LOG_DIR}/lifecyclemanager-${TARGET_PORT}.pid"
+    stop_by_pid_file "${PID_FILE}"
+    ;;
+  all)
+    found=0
+    failed=0
+    for pid_file in "${LOG_DIR}"/lifecyclemanager-*.pid; do
+      [ -f "${pid_file}" ] || continue
+      found=1
+      # Capture the return code without letting `set -e` abort the loop on
+      # the first non-zero exit (e.g. a stale PID file we cleaned up, or a
+      # single instance we failed to kill — we still want to try the rest).
+      rc=0
+      stop_by_pid_file "${pid_file}" || rc=$?
+      if [ "${rc}" -ne 0 ]; then
+        echo "Warning: stop_by_pid_file exited ${rc} for ${pid_file}" >&2
+        failed=1
+      fi
+    done
+    if [ "${found}" -eq 0 ]; then
+      echo "No running LifecycleManager instances found."
+    fi
+    exit "${failed}"
+    ;;
+esac

Reply via email to