#!/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
Bug Description
When a field declares both
store: falseandoptional: falseempties the WHOLE collection on next restart. Not just the field.Reproduction Steps
Save as
reproduce.shand runbash reproduce.sh. Needs Docker and curl.reproduce.shExpected vs Actual
Expected: 5 documents before restart, 5 after.
Actual: 5 before, 0 after.
vec_optionalis the control. Only difference isoptional: true. Survives the restart. So the trigger isoptional: false, notstore: falsealone.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:1330and:1350erase everystore: falsefield before the RocksDB write.src/validator.cpp:665skips the clean "not found in the document" rejection, because that check is guarded by&& a_field.store. Next line,:670, doesdocument[field_name], which default-constructs and inserts a null.:672skips the null handler because the field is not optional.coerce_elementturns the null into[](:65-67), then the dimension check at:108-110rejects it.src/collection_manager.cpp:2957-2963catches the index error and drops it. The return is commented out:Collection is then added via
add_to_collections. OnlyIndexed 0/Nis logged. Node stays healthy.Suggested: reject
store: false+optional: falseat create and alter time. Log the dropped error. Fail readiness when a collection loads 0 of N with N > 0.