Sitelet https://github.com/typesense/typesense/issues/3022
Skip to content

Collection isn't loaded when any field has store: false and optional: false . #3022

Description

@alangmartini

Bug Description

When a field declares both store: false and optional: false empties the WHOLE collection on next restart. Not just the field.

Reproduction Steps

Save as reproduce.sh and run bash reproduce.sh. Needs Docker and curl.

reproduce.sh
#!/bin/bash
# Issue: A field declared both `store: false` and `optional: false` makes the ENTIRE
#        collection load 0 documents on restart, with no error logged.
# Typesense Version: verified on 30.2 and 31.0.rc13 (both affected)
# Description:
#   `store: false` strips the field from the JSON before it is written to RocksDB.
#   On restart the document is read back without that field. Because the field is
#   `optional: false`, validation materialises a null for the missing key, coerces it
#   to an empty value, and then rejects the document. Every document fails, so the
#   collection loads 0 of N and serves an empty index while /health still returns
#   {"ok":true} and nothing is logged to say why.
#
#   Case 1 (bug)     : float[] num_dim, optional=false, store=false  -> empty after restart
#   Case 2 (control) : float[] num_dim, optional=TRUE,  store=false  -> survives restart
#   Case 3 (scope)   : string,          optional=false, store=false  -> shows this is not
#                                                                       vector specific

set -e

# On Windows Git Bash, MSYS rewrites absolute paths like /data into
# C:/Program Files/Git/data before handing them to docker.exe, which breaks the
# bind mount and --data-dir so Typesense cannot find its data directory.
# Turning path conversion off keeps those paths literal. It is inert on Linux
# and macOS, so the script stays portable.
export MSYS_NO_PATHCONV=1

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

TYPESENSE_API_KEY=xyz
PORT=8108
TYPESENSE_HOST=http://localhost:${PORT}
CONTAINER_NAME=typesense-issue-store-false-non-optional
DATA_VOLUME=typesense-data-${CONTAINER_NAME}   # named docker volume (no host path)
TYPESENSE_VERSION=${TYPESENSE_VERSION:-30.2}   # override to test another release

# ============================================================================
# CLEANUP FUNCTION
# ============================================================================

cleanup() {
  echo ""
  echo "=== Cleanup ==="
  docker stop ${CONTAINER_NAME} 2>/dev/null || true
  docker rm ${CONTAINER_NAME} 2>/dev/null || true
  docker volume rm ${DATA_VOLUME} 2>/dev/null || true
  echo "Cleanup complete"
}

trap cleanup EXIT

# ============================================================================
# HELPERS
# ============================================================================

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

# After creating a collection, Typesense may queue the operation via Raft
# consensus. The API returns immediately, but the collection may not be
# queryable yet. This function polls until the collection is available.
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
}

# Wildcard search, print just the `found` count.
found_count() {
  local collection=$1
  curl -s "${TYPESENSE_HOST}/collections/${collection}/documents/search?q=*&per_page=1" \
    -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" 2>/dev/null \
    | grep -o '"found":[0-9]*' | head -1 | cut -d: -f2
}

# ============================================================================
# SETUP TYPESENSE
# ============================================================================

echo "=== Setting up Typesense ${TYPESENSE_VERSION} ==="

docker stop ${CONTAINER_NAME} 2>/dev/null || true
docker rm ${CONTAINER_NAME} 2>/dev/null || true
docker volume rm ${DATA_VOLUME} 2>/dev/null || true

docker run -d \
  --name ${CONTAINER_NAME} \
  -p ${PORT}:8108 \
  -v ${DATA_VOLUME}:/data \
  typesense/typesense:${TYPESENSE_VERSION} \
  --data-dir /data \
  --api-key=${TYPESENSE_API_KEY} \
  --enable-cors > /dev/null

echo "Waiting for Typesense to be ready..."
wait_for_health
echo "Typesense is ready"

# ============================================================================
# CREATE COLLECTIONS
# ============================================================================

echo ""
echo "=== Creating Collections ==="

# Case 1: the bug. Vector field is non-optional AND not stored.
curl -s "${TYPESENSE_HOST}/collections" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "vec_required",
    "fields": [
      {"name": "title", "type": "string"},
      {"name": "embedding", "type": "float[]", "num_dim": 4, "optional": false, "store": false}
    ]
  }' > /dev/null
wait_for_collection "vec_required"

# Case 2: control. Identical, except the vector field is optional.
curl -s "${TYPESENSE_HOST}/collections" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "vec_optional",
    "fields": [
      {"name": "title", "type": "string"},
      {"name": "embedding", "type": "float[]", "num_dim": 4, "optional": true, "store": false}
    ]
  }' > /dev/null
wait_for_collection "vec_optional"

# Case 3: scope. Plain string field, non-optional AND not stored. No vectors involved.
curl -s "${TYPESENSE_HOST}/collections" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "str_required",
    "fields": [
      {"name": "title", "type": "string"},
      {"name": "secret", "type": "string", "optional": false, "store": false}
    ]
  }' > /dev/null
wait_for_collection "str_required"

echo "Collections created: vec_required, vec_optional, str_required"

# ============================================================================
# IMPORT DOCUMENTS
# ============================================================================

echo ""
echo "=== Importing 5 documents into each collection ==="

for COLL in vec_required vec_optional; do
  curl -s "${TYPESENSE_HOST}/collections/${COLL}/documents/import?action=create" \
    -X POST \
    -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
    -H "Content-Type: text/plain" \
    --data-binary @- > /dev/null <<'EOF'
{"id": "1", "title": "alpha", "embedding": [0.1, 0.2, 0.3, 0.4]}
{"id": "2", "title": "bravo", "embedding": [0.2, 0.3, 0.4, 0.5]}
{"id": "3", "title": "charlie", "embedding": [0.3, 0.4, 0.5, 0.6]}
{"id": "4", "title": "delta", "embedding": [0.4, 0.5, 0.6, 0.7]}
{"id": "5", "title": "echo", "embedding": [0.5, 0.6, 0.7, 0.8]}
EOF
done

curl -s "${TYPESENSE_HOST}/collections/str_required/documents/import?action=create" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  -H "Content-Type: text/plain" \
  --data-binary @- > /dev/null <<'EOF'
{"id": "1", "title": "alpha", "secret": "s1"}
{"id": "2", "title": "bravo", "secret": "s2"}
{"id": "3", "title": "charlie", "secret": "s3"}
{"id": "4", "title": "delta", "secret": "s4"}
{"id": "5", "title": "echo", "secret": "s5"}
EOF

echo "Documents imported"

# ============================================================================
# BEFORE RESTART
# ============================================================================

echo ""
echo "=== BEFORE restart (q=*) ==="
BEFORE_VEC_REQ=$(found_count vec_required)
BEFORE_VEC_OPT=$(found_count vec_optional)
BEFORE_STR_REQ=$(found_count str_required)
echo "  vec_required (optional=false, store=false) : found=${BEFORE_VEC_REQ}"
echo "  vec_optional (optional=true,  store=false) : found=${BEFORE_VEC_OPT}"
echo "  str_required (optional=false, store=false) : found=${BEFORE_STR_REQ}"

# ============================================================================
# SNAPSHOT, THEN RESTART
# ============================================================================
# The snapshot truncates the Raft write-ahead log. Without it, restarting would
# replay the original import requests (which still carry the field values) and
# repopulate the index, masking the on-disk reload path this bug lives in.
# A real cluster hits the same cold-load path after an OOM kill, a version
# upgrade, a node replacement, or a clone.

echo ""
echo "=== Snapshotting (truncates the Raft log) then restarting the container ==="
curl -s "${TYPESENSE_HOST}/operations/snapshot?snapshot_path=/data/snapshot" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" > /dev/null
sleep 5

docker restart ${CONTAINER_NAME} > /dev/null
wait_for_health
sleep 8
echo "Typesense restarted and reports healthy"

# ============================================================================
# AFTER RESTART
# ============================================================================

echo ""
echo "=== AFTER restart (q=*) ==="
AFTER_VEC_REQ=$(found_count vec_required)
AFTER_VEC_OPT=$(found_count vec_optional)
AFTER_STR_REQ=$(found_count str_required)
echo "  vec_required (optional=false, store=false) : found=${AFTER_VEC_REQ}"
echo "  vec_optional (optional=true,  store=false) : found=${AFTER_VEC_OPT}"
echo "  str_required (optional=false, store=false) : found=${AFTER_STR_REQ}"

echo ""
echo "=== /health after the wipe ==="
curl -s "${TYPESENSE_HOST}/health"
echo ""

echo ""
echo "=== Documents are still on disk: unfiltered export of vec_required ==="
EXPORT_LINES=$(curl -s "${TYPESENSE_HOST}/collections/vec_required/documents/export" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" | grep -c '"id"' || true)
echo "  export returned ${EXPORT_LINES} documents (search returns ${AFTER_VEC_REQ})"

# ============================================================================
# LOG EVIDENCE
# ============================================================================

echo ""
echo "=== Collection load lines from the server log ==="
docker logs ${CONTAINER_NAME} 2>&1 | grep "documents into collection" || echo "  (none)"

echo ""
echo "=== Any logged reason for the failure? ==="
REASON_LINES=$(docker logs ${CONTAINER_NAME} 2>&1 \
  | grep -Ec "dimensions|not found in the document|Error while loading|Failed to load" || true)
if [ "${REASON_LINES}" -eq 0 ]; then
  echo "  NONE. 0 log lines mention dimensions / not found in the document / load failure."
else
  echo "  ${REASON_LINES} line(s) found:"
  docker logs ${CONTAINER_NAME} 2>&1 | grep -E "dimensions|not found in the document|Error while loading|Failed to load"
fi

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

echo ""
echo "============================================================"
if [ "${BEFORE_VEC_REQ}" = "5" ] && [ "${AFTER_VEC_REQ}" = "0" ] \
   && [ "${BEFORE_VEC_OPT}" = "5" ] && [ "${AFTER_VEC_OPT}" = "5" ] \
   && [ "${BEFORE_STR_REQ}" = "5" ] && [ "${AFTER_STR_REQ}" = "0" ] \
   && [ "${EXPORT_LINES}" = "5" ] && [ "${REASON_LINES}" -eq 0 ]; then
  echo "BUG REPRODUCED"
  echo "  store:false + optional:false  -> 5 docs before restart, 0 after (vec and string)"
  echo "  store:false + optional:true   -> 5 docs before restart, 5 after"
  echo "  documents survive on disk     -> unfiltered export still returns 5"
  echo "  no reason logged              -> 0 explanatory log lines"
  echo "============================================================"
  exit 0
else
  echo "Reproducer did not hit the expected state"
  echo "  vec_required before=${BEFORE_VEC_REQ} after=${AFTER_VEC_REQ} (expected 5 -> 0)"
  echo "  vec_optional before=${BEFORE_VEC_OPT} after=${AFTER_VEC_OPT} (expected 5 -> 5)"
  echo "  str_required before=${BEFORE_STR_REQ} after=${AFTER_STR_REQ} (expected 5 -> 0)"
  echo "  export lines=${EXPORT_LINES} (expected 5)"
  echo "  explanatory log lines=${REASON_LINES} (expected 0)"
  echo "============================================================"
  exit 1
fi

Expected vs Actual

Expected: 5 documents before restart, 5 after.

Actual: 5 before, 0 after.

=== BEFORE restart (q=*) ===
  vec_required (optional=false, store=false) : found=5
  vec_optional (optional=true,  store=false) : found=5
  str_required (optional=false, store=false) : found=5

=== AFTER restart (q=*) ===
  vec_required (optional=false, store=false) : found=0
  vec_optional (optional=true,  store=false) : found=5
  str_required (optional=false, store=false) : found=0

=== /health after the wipe ===
{"ok":true}

=== Documents are still on disk: unfiltered export of vec_required ===
  export returned 5 documents (search returns 0)

=== Collection load lines from the server log ===
I ... collection_manager.cpp:2119] Indexed 0/5 documents into collection str_required
I ... collection_manager.cpp:2119] Indexed 0/5 documents into collection vec_required

=== Any logged reason for the failure? ===
  NONE. 0 log lines mention dimensions / not found in the document / load failure.

vec_optional is the control. Only difference is optional: true. Survives the restart. So the trigger is optional: false, not store: false alone.

Environment

Typesense 30.2, Docker image typesense/typesense:30.2, single node.
Also reproduced on typesense/typesense:31.0.rc13, same result.

Schema / Configuration

{
  "name": "vec_required",
  "fields": [
    {"name": "title", "type": "string"},
    {"name": "embedding", "type": "float[]", "num_dim": 4, "optional": false, "store": false}
  ]
}

Additional Context

Line numbers from v30.1-116-g2b1ea800, whose numbering matches the 31.0.rc13 binary.

src/collection.cpp:1330 and :1350 erase every store: false field before the RocksDB write.

src/validator.cpp:665 skips the clean "not found in the document" rejection, because that check is guarded by && a_field.store. Next line, :670, does document[field_name], which default-constructs and inserts a null. :672 skips the null handler because the field is not optional. coerce_element turns the null into [] (:65-67), then the dimension check at :108-110 rejects it.

src/collection_manager.cpp:2957-2963 catches the index error and drops it. The return is commented out:

if(num_indexed != num_records) {
    const std::string& index_error = get_first_index_error(index_records);
    if(!index_error.empty()) {
        // for now, we will just ignore errors during loading of collection
        //return Option<bool>(400, index_error);
    }
}

Collection is then added via add_to_collections. Only Indexed 0/N is logged. Node stays healthy.

Suggested: reject store: false + optional: false at create and alter time. Log the dropped error. Fail readiness when a collection loads 0 of N with N > 0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions