#2950·typesense

create_collection with local embedding model aborts server when CUDA execution provider fails to init; committed write becomes a crash loop

Author: alangmartiniCreated Jun 9, 2026Updated Sep 8, 2026
Labelsbug

Bug Description

Server with onnxruntime CUDA provider libs installed (official typesense-gpu-deps package) but unusable CUDA (no GPU device, or broken driver): create_collection with a local embedding model field (ts/e5-small, ts/clip-vit-b-p32, any local model) kills the whole typesense-server process. Exception thrown inside libonnxruntime_providers_cuda.so cannot unwind across the shared-library boundary into the statically linked binary. Process dies with SIGABRT: Typesense 30.2 is terminating abruptly.

Worse: the create_collection write is already committed to the Raft log before model init runs. Crash repeats on replay until skip_index cancels the write. Every NEW create with a local embed field is a fresh poison write and crashes the server again. On HA clusters the write is replicated, all nodes crash together, cluster enters a crash loop that never converges while the client keeps creating collections. Observed on a 3-node HA GPU deployment (v30.2): all 3 nodes crash-looped every ~3 minutes, identical traces ending in CUDAExecutionProviderInfo::ToProviderOptions.

Contrast: when CUDA runtime deps are merely incomplete (libcublasLt.so.11 missing), provider bridge fails with a clean ONNXRuntimeError, exception is caught in batched_indexer.cpp, create returns 400 Bad request., server stays up. Crash path should behave the same.

Reproduction Steps

Save as reproduce.sh and run bash reproduce.sh. Needs Docker and curl. First run downloads ~50 MB (typesense-gpu-deps deb) and pulls nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 (~3 GB) for the CUDA 11 runtime libs; both cached under ./cache/.

reproduce.sh
bash
#!/bin/bash
# Issue: create_collection with a local embedding model aborts the whole server
#        when the ONNX CUDA execution provider fails to initialize, and the
#        committed write turns into a restart crash loop.
# Typesense Version: 30.2
# Description:
#   On a server where the onnxruntime CUDA provider libs are present (as
#   installed by the official typesense-gpu-deps package) but CUDA is not
#   usable (no GPU / broken driver), creating a collection with a local
#   embedding model field (e.g. ts/e5-small) aborts the typesense-server
#   process: the exception thrown inside libonnxruntime_providers_cuda.so
#   cannot unwind across the shared-library boundary into the statically
#   linked binary, so the process dies with SIGABRT
#   ("Typesense 30.2 is terminating abruptly.").
#   Because the create_collection write is already committed to the Raft log,
#   the crash recurs on replay until the skip_index mechanism cancels it, and
#   every NEW create with a local embed field crashes the server again.
#
#   Case 1 (control): stock typesense/typesense:30.2, no CUDA libs.
#                     Create succeeds (CPU fallback), server stays up.
#   Case 2 (bug):     same image + gpu-deps provider libs + CUDA 11 runtime
#                     libs, no GPU. Same create kills the server.
#   Case 3 (loop):    restart on the same data dir: server skips the poison
#                     write (collection is silently dropped) and a second
#                     create with the same embed field kills it again.
#
# NOTE: first run downloads ~50 MB (typesense-gpu-deps deb) and pulls the
#       nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 image (~3 GB) to source
#       the CUDA 11 runtime libs. Both are cached under ./cache for reruns.

set -e

# ============================================================================
# CONFIGURATION
# ============================================================================

TYPESENSE_API_KEY=xyz
PORT=8231
TYPESENSE_HOST=http://localhost:${PORT}
VERSION=30.2
CONTAINER_NAME=typesense-issue-cuda-ep-abort
DATA_DIR_CTRL=$(pwd)/typesense-data-${CONTAINER_NAME}-ctrl
DATA_DIR_BUG=$(pwd)/typesense-data-${CONTAINER_NAME}-bug
CACHE_DIR=$(pwd)/cache
GPU_DEPS_DEB_URL="https://dl.typesense.org/releases/${VERSION}/typesense-gpu-deps-${VERSION}-amd64.deb"
CUDA_IMAGE="nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04"

# Windows (git-bash) path handling for docker -v
if command -v cygpath > /dev/null 2>&1; then
  hostpath() { cygpath -m "$1"; }
  export MSYS_NO_PATHCONV=1
else
  hostpath() { echo "$1"; }
fi

EMBED_SCHEMA() {
  # $1 = collection name
  cat <<EOF
{"name":"$1","fields":[
  {"name":"title","type":"string"},
  {"name":"embedding","type":"float[]","embed":{"from":["title"],"model_config":{"model_name":"ts/e5-small"}}}
]}
EOF
}

# ============================================================================
# CLEANUP (cache/ is kept intentionally: deb + extracted CUDA libs)
# ============================================================================

cleanup() {
  echo ""
  echo "=== Cleanup ==="
  docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
  rm -rf "${DATA_DIR_CTRL}" "${DATA_DIR_BUG}"
  echo "Cleanup complete (cache/ kept for reruns)"
}
trap cleanup EXIT

wait_for_health() {
  local max_wait=${1:-90}
  local count=0
  while [ $count -lt $max_wait ]; do
    if curl -s -m 2 "${TYPESENSE_HOST}/health" 2>/dev/null | grep -q '"ok":true'; then
      return 0
    fi
    sleep 1
    count=$((count + 1))
  done
  echo "WARNING: server not healthy after ${max_wait}s"
  return 1
}

wait_for_collection() {
  local collection=$1
  local max_wait=${2:-30}
  local count=0
  while [ $count -lt $max_wait ]; do
    if curl -s -o /dev/null -w "%{http_code}" "${TYPESENSE_HOST}/collections/${collection}" \
      -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" 2>/dev/null | grep -q "200"; then
      return 0
    fi
    sleep 1
    count=$((count + 1))
  done
  echo "WARNING: Collection '${collection}' not ready after ${max_wait}s"
  return 1
}

wait_for_container_death() {
  local max_wait=${1:-300}
  local count=0
  while [ $count -lt $max_wait ]; do
    if [ "$(docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME} 2>/dev/null)" = "false" ]; then
      return 0
    fi
    sleep 1
    count=$((count + 1))
  done
  return 1
}

# ============================================================================
# FETCH GPU DEPS (cached)
# ============================================================================

mkdir -p "${CACHE_DIR}"
HP_CACHE=$(hostpath "${CACHE_DIR}")

if [ ! -f "${CACHE_DIR}/extract/usr/lib/libonnxruntime_providers_cuda.so" ]; then
  echo "=== Downloading typesense-gpu-deps ${VERSION} (~50 MB) ==="
  curl -sL -o "${CACHE_DIR}/gpu-deps.deb" "${GPU_DEPS_DEB_URL}"
  docker run --rm -v "${HP_CACHE}:/work" debian:bookworm-slim \
    bash -c "dpkg-deb -x /work/gpu-deps.deb /work/extract" > /dev/null
fi

if [ ! -f "${CACHE_DIR}/cuda-libs/libcublasLt.so.11" ]; then
  echo "=== Extracting CUDA 11 runtime libs from ${CUDA_IMAGE} (~3 GB pull on first run) ==="
  mkdir -p "${CACHE_DIR}/cuda-libs"
  docker run --rm -v "${HP_CACHE}/cuda-libs:/out" ${CUDA_IMAGE} \
    bash -c "cp -L /usr/local/cuda/lib64/libcublasLt.so.11 /usr/local/cuda/lib64/libcublas.so.11 \
                   /usr/local/cuda/lib64/libcufft.so.10 /usr/local/cuda/lib64/libcudart.so.11.0 \
                   /usr/lib/x86_64-linux-gnu/libcudnn.so.8 /out/" > /dev/null
fi

# ============================================================================
# CASE 1 (control): stock image, no CUDA libs -> create succeeds, CPU fallback
# ============================================================================

echo ""
echo "=== Case 1 (control): stock ${VERSION}, no CUDA provider libs ==="
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
mkdir -p "${DATA_DIR_CTRL}"
docker run -d --name ${CONTAINER_NAME} -p ${PORT}:8108 \
  -v "$(hostpath "${DATA_DIR_CTRL}"):/data" \
  typesense/typesense:${VERSION} --data-dir /data --api-key=${TYPESENSE_API_KEY} > /dev/null
wait_for_health

CASE1_CODE=$(curl -s -m 300 -o /dev/null -w "%{http_code}" "${TYPESENSE_HOST}/collections" -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" -H "Content-Type: application/json" \
  -d "$(EMBED_SCHEMA items)" || true)
wait_for_collection items || true
CASE1_RUNNING=$(docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME})
echo "Case 1: create returned HTTP ${CASE1_CODE}, server running=${CASE1_RUNNING}"
docker logs ${CONTAINER_NAME} 2>&1 | grep -m1 "ONNX shared libs: off" || true

docker rm -f ${CONTAINER_NAME} > /dev/null

# ============================================================================
# CASE 2 (bug): CUDA provider libs mounted, no GPU -> same create aborts server
# ============================================================================

echo ""
echo "=== Case 2 (bug): + gpu-deps provider libs + CUDA 11 runtime libs, no GPU ==="
mkdir -p "${DATA_DIR_BUG}"
# reuse the already-downloaded model so case 2 does not re-download it
if [ -d "${DATA_DIR_CTRL}/models" ]; then
  cp -r "${DATA_DIR_CTRL}/models" "${DATA_DIR_BUG}/models" 2>/dev/null || true
fi
docker run -d --name ${CONTAINER_NAME} -p ${PORT}:8108 \
  -v "$(hostpath "${DATA_DIR_BUG}"):/data" \
  -v "${HP_CACHE}/extract/usr/lib/libonnxruntime_providers_shared.so:/usr/lib/libonnxruntime_providers_shared.so:ro" \
  -v "${HP_CACHE}/extract/usr/lib/libonnxruntime_providers_cuda.so:/usr/lib/libonnxruntime_providers_cuda.so:ro" \
  -v "${HP_CACHE}/cuda-libs:/usr/lib/cuda-rt:ro" \
  -e LD_LIBRARY_PATH=/usr/lib/cuda-rt \
  typesense/typesense:${VERSION} --data-dir /data --api-key=${TYPESENSE_API_KEY} > /dev/null
wait_for_health

curl -s -m 300 "${TYPESENSE_HOST}/collections" -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" -H "Content-Type: application/json" \
  -d "$(EMBED_SCHEMA items)" > /dev/null || true
wait_for_container_death 300 && CASE2_DEAD=1 || CASE2_DEAD=0
CASE2_EXIT=$(docker inspect -f '{{.State.ExitCode}}' ${CONTAINER_NAME})
CASE2_TRACE=$(docker logs ${CONTAINER_NAME} 2>&1 | grep -c "libonnxruntime_providers_cuda.so" || true)
CASE2_ABRUPT=$(docker logs ${CONTAINER_NAME} 2>&1 | grep -c "terminating abruptly" || true)
echo "Case 2: server dead=${CASE2_DEAD} exit_code=${CASE2_EXIT}, stack frames in libonnxruntime_providers_cuda.so=${CASE2_TRACE}, 'terminating abruptly'=${CASE2_ABRUPT}"
docker logs ${CONTAINER_NAME} 2>&1 | grep -E "CreateProvider|terminating abruptly|Aborted" | tail -4

# ============================================================================
# CASE 3 (loop): restart same data dir -> poison write skipped (collection
#                silently dropped), next create with embed field crashes again
# ============================================================================

echo ""
echo "=== Case 3 (loop): restart on same data dir ==="
docker start ${CONTAINER_NAME} > /dev/null
wait_for_health
CASE3_SKIPS=$(docker logs ${CONTAINER_NAME} 2>&1 | grep -c "Skipping write log index" || true)
CASE3_COL=$(curl -s -o /dev/null -w "%{http_code}" "${TYPESENSE_HOST}/collections/items" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" || true)
echo "Case 3: after restart, 'Skipping write log index' lines=${CASE3_SKIPS}, GET /collections/items=${CASE3_COL} (write cancelled)"

curl -s -m 300 "${TYPESENSE_HOST}/collections" -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" -H "Content-Type: application/json" \
  -d "$(EMBED_SCHEMA items2)" > /dev/null || true
wait_for_container_death 300 && CASE3_DEAD=1 || CASE3_DEAD=0
echo "Case 3: second create with embed field -> server dead again=${CASE3_DEAD}"

# ============================================================================
# VERDICT
# ============================================================================

echo ""
echo "=== SUMMARY ==="
echo "Case 1 (no CUDA libs):     create HTTP=${CASE1_CODE}, server running=${CASE1_RUNNING}   (expected: 201, true)"
echo "Case 2 (CUDA libs, no GPU): server dead=${CASE2_DEAD}, cuda frames=${CASE2_TRACE}, abrupt=${CASE2_ABRUPT}   (expected: 1, >0, >0)"
echo "Case 3 (restart):          skips=${CASE3_SKIPS}, items=${CASE3_COL}, dead again=${CASE3_DEAD}   (expected: >0, 404, 1)"

if [ "${CASE1_CODE}" = "201" ] && [ "${CASE1_RUNNING}" = "true" ] && \
   [ "${CASE2_DEAD}" = "1" ] && [ "${CASE2_TRACE}" -gt 0 ] && [ "${CASE2_ABRUPT}" -gt 0 ] && \
   [ "${CASE3_SKIPS}" -gt 0 ] && [ "${CASE3_COL}" = "404" ] && [ "${CASE3_DEAD}" = "1" ]; then
  echo ""
  echo "BUG REPRODUCED: local embed model create aborts the server when CUDA EP init fails, and the committed write crash-loops on restart."
  exit 0
else
  echo ""
  echo "Reproducer did not hit the expected state"
  exit 1
fi

Expected vs Actual

Expected: create with local embed model + failed CUDA EP init returns 400 with a model init error, like the incomplete-deps case. Server stays up.

Actual, Case 2 (provider libs + CUDA 11 runtime libs, no GPU):

E ... backward.hpp:4200] #7  Object "/usr/lib/libonnxruntime_providers_cuda.so", in onnxruntime::CUDAProviderFactory::CreateProvider() [clone .cold]
E ... backward.hpp:4200] #6  Object "/usr/lib/x86_64-linux-gnu/libgcc_s.so.1", in _Unwind_Resume
E ... backward.hpp:4200] #2  Object "/usr/lib/x86_64-linux-gnu/libc.so.6", in abort
Aborted (Signal sent by tkill() 1 0)
E ... typesense_server.cpp:171] Typesense 30.2 is terminating abruptly.

Actual, Case 3 (restart on same data dir):

E ... Skipping write log index 7 which seems to have triggered a crash previously.

Collection from the crashed create: GET /collections/items returns 404. Write silently dropped. Next create with a local embed field crashes the server again.

Variant of the same failure seen on a real GPU host with a faulting provider: SIGSEGV instead of SIGABRT, trace ends in:

#3 Object "/usr/lib/libonnxruntime_providers_cuda.so", in onnxruntime::CUDAExecutionProvider::GetProviderOptions[abi:cxx11]() const
#2 Object "/usr/lib/libonnxruntime_providers_cuda.so", in onnxruntime::CUDAExecutionProviderInfo::ToProviderOptions[abi:cxx11](...)

Environment

  • Typesense 30.2, Docker image typesense/typesense:30.2 (amd64)
  • CUDA provider libs from https://dl.typesense.org/releases/30.2/typesense-gpu-deps-30.2-amd64.deb
  • CUDA 11.8 runtime libs from nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
  • No GPU. Also reproduces with GPU present but faulting driver.

Schema / Configuration

json
{
  "name": "items",
  "fields": [
    {"name": "title", "type": "string"},
    {"name": "embedding", "type": "float[]", "embed": {"from": ["title"], "model_config": {"model_name": "ts/e5-small"}}}
  ]
}

Additional Context

  • src/text_embedder.cpp:14-28 (v30.2): CUDA EP appended unconditionally when CUDAExecutionProvider is available and libonnxruntime_providers_shared.so dlopens. src/text_embedder.cpp:35 constructs Ort::Session. No try/catch, no CPU fallback.
  • src/embedder_manager.cpp:166: std::make_shared<TextEmbedder> called with no fault isolation.
  • Model init runs inside the Raft apply path: src/core_api.cpp:307src/collection_manager.cpp:1843src/field.cpp:883src/embedder_manager.cpp:22. A failing model becomes a committed, replicated poison write.
  • src/batched_indexer.cpp:264-267: skip_index only skips indices recorded by a previous crash. Stream of new poison writes outruns it; loop never converges.
  • Suggested fix: catch CUDA EP registration failure in TextEmbedder and fall back to CPU EP; longer term, validate local model init before committing the write to the Raft log.