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

search_cutoff_ms does not bound a geopoint radius filter #3019

Description

@alangmartini

search_cutoff_ms does not bound a geopoint radius filter

Bug Description

filter_by=location:(lat,lng,N mi) is computed eagerly and in full while the filter tree is built. No deadline is checked during that work, so search_cutoff_ms cannot stop it.

The response still reports "search_cutoff": true. That is misleading: nothing was curtailed. The query runs past the deadline and returns the COMPLETE result set. The same search_cutoff_ms value on a non-geo query on the same collection does curtail: time drops to the budget and found shrinks. That contrast is the bug.

Cost scales with the number of documents carrying the geopoint field, not with the number of matches. At 1M docs the filter runs 6x to 17x over a 20 ms deadline. At tens of millions of docs on small vCPU counts the same query shape does not return inside a 60 s client timeout, and since no deadline applies, each query holds a search thread for its entire life until the thread pool is exhausted.

Reproduction Steps

Save as reproduce.sh and run bash reproduce.sh. Needs Docker and curl. It defaults to 31.0.rc13.

reproduce.sh
#!/bin/bash
# Issue: search_cutoff_ms does not bound a geopoint radius filter
# Typesense Version: 31.0.rc13 (also reproduces on 30.2 and 29.1)
# Description:
#   filter_by=location:(lat,lng,N mi) is evaluated eagerly and in full while the
#   filter tree is being built. No deadline is checked during that work, so
#   search_cutoff_ms cannot bound it. The response still reports
#   "search_cutoff": true, but nothing was actually curtailed: the query runs far
#   past the deadline and returns the COMPLETE result set.
#
#   The same search_cutoff_ms value on a non-geo query on the same collection
#   does work: it stops early and returns a truncated `found`. That contrast is
#   the reproducer.

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-geo-cutoff
DATA_DIR="$(pwd)/typesense-data-${CONTAINER_NAME}"
# Pinned to the latest release candidate at the time of writing. Override with
# VERSION=30.2 or VERSION=29.1 to confirm the same behaviour on older releases.
VERSION=${VERSION:-31.0.rc13}

# Import staging files are referenced by RELATIVE path on purpose. MSYS_NO_PATHCONV
# above (needed for the docker bind mount) also stops Git Bash rewriting /c/... into
# C:/... for curl's @file argument, so an absolute path here breaks on Windows.
SEED_FILE="seed-${CONTAINER_NAME}.jsonl"
CHUNK_PREFIX="seed-${CONTAINER_NAME}.chunk."

# Number of documents carrying the geopoint field. The filter cost scales with
# this number, not with how many documents match.
NDOCS=${NDOCS:-1000000}

# Deadline we ask the server to honour.
CUTOFF_MS=${CUTOFF_MS:-20}

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

cleanup() {
  echo ""
  echo "=== Cleanup ==="
  docker stop ${CONTAINER_NAME} 2>/dev/null || true
  docker rm ${CONTAINER_NAME} 2>/dev/null || true
  rm -rf "${DATA_DIR}" 2>/dev/null || true
  rm -f "${SEED_FILE}" ${CHUNK_PREFIX}* 2>/dev/null || true
  echo "Cleanup complete"
}

trap cleanup EXIT

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

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

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

mkdir -p "${DATA_DIR}"

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

echo "Waiting for Typesense to report ok:true ..."
WAITED=0
until curl -s "${TYPESENSE_HOST}/health" 2>/dev/null | grep -q '"ok":true'; do
  sleep 1
  WAITED=$((WAITED + 1))
  if [ ${WAITED} -gt 120 ]; then
    echo "Typesense did not become healthy in 120s"
    exit 1
  fi
done
echo "Typesense is ready!"

# ============================================================================
# HELPER: WAIT FOR COLLECTION
# ============================================================================

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
}

# ============================================================================
# CREATE COLLECTION
# ============================================================================

echo ""
echo "=== Creating Collection ==="

curl -s "${TYPESENSE_HOST}/collections" \
  -X POST \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "places",
    "fields": [
      {"name": "title", "type": "string"},
      {"name": "kind", "type": "string", "facet": true},
      {"name": "location", "type": "geopoint"}
    ]
  }' > /dev/null

wait_for_collection "places"
echo "Collection created"

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

echo ""
echo "=== Importing ${NDOCS} documents (deterministic lattice, no RNG) ==="

# Points are laid out on a fixed 1000 x 1000 lattice inside a 1 degree box.
# Pure integer arithmetic, so the dataset is identical on every awk
# implementation and every run.
awk -v n="${NDOCS}" 'BEGIN{
  for (i = 0; i < n; i++) {
    lat = 31.0 + (i % 1000) * 0.001;
    lng = -97.7 + (int(i / 1000) % 1000) * 0.001;
    printf "{\"id\":\"%d\",\"title\":\"place %d\",\"kind\":\"shop\",\"location\":[%.6f,%.6f]}\n", i, i, lat, lng;
  }
}' > "${SEED_FILE}"

split -l 50000 "${SEED_FILE}" "${CHUNK_PREFIX}"

for f in ${CHUNK_PREFIX}*; do
  curl -s "${TYPESENSE_HOST}/collections/places/documents/import?action=create&batch_size=10000" \
    -X POST \
    -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
    -H "Content-Type: text/plain" \
    --data-binary @"${f}" > /dev/null
  printf "."
done
echo ""

rm -f "${SEED_FILE}" ${CHUNK_PREFIX}*

IMPORTED=$(curl -s "${TYPESENSE_HOST}/collections/places" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  | grep -oE '"num_documents":[0-9]+' | cut -d: -f2)
echo "Imported ${IMPORTED} documents"

# ============================================================================
# REPRODUCE THE ISSUE
# ============================================================================

# Runs a search and exports FOUND / TIME_MS / CUTOFF_FLAG from the response.
probe() {
  local label="$1"; shift
  local body
  body=$(curl -s --max-time 300 "$@")
  FOUND=$(echo "${body}" | grep -oE '"found":[0-9]+' | head -1 | cut -d: -f2)
  TIME_MS=$(echo "${body}" | grep -oE '"search_time_ms":[0-9]+' | head -1 | cut -d: -f2)
  CUTOFF_FLAG=$(echo "${body}" | grep -oE '"search_cutoff":(true|false)' | head -1 | cut -d: -f2)
  FOUND=${FOUND:-NA}
  TIME_MS=${TIME_MS:-NA}
  CUTOFF_FLAG=${CUTOFF_FLAG:-NA}
  printf "%-46s found=%-10s search_time_ms=%-6s search_cutoff=%s\n" \
    "${label}" "${FOUND}" "${TIME_MS}" "${CUTOFF_FLAG}"
}

echo ""
echo "=== CONTROL: non-geo query, search_cutoff_ms=${CUTOFF_MS} is honoured ==="

probe "1. text+facet, no cutoff" -G "${TYPESENSE_HOST}/collections/places/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  --data-urlencode 'q=place' --data-urlencode 'query_by=title' \
  --data-urlencode 'facet_by=kind' --data-urlencode 'per_page=1'
CTL_FOUND_NOCUT=${FOUND}
CTL_TIME_NOCUT=${TIME_MS}

probe "2. text+facet, search_cutoff_ms=${CUTOFF_MS}" -G "${TYPESENSE_HOST}/collections/places/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  --data-urlencode 'q=place' --data-urlencode 'query_by=title' \
  --data-urlencode 'facet_by=kind' --data-urlencode 'per_page=1' \
  --data-urlencode "search_cutoff_ms=${CUTOFF_MS}"
CTL_FOUND_CUT=${FOUND}
CTL_TIME_CUT=${TIME_MS}

echo ""
echo "=== BUG: geopoint radius filter ignores search_cutoff_ms=${CUTOFF_MS} ==="

probe "3. geo radius 30 mi, no cutoff" -G "${TYPESENSE_HOST}/collections/places/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  --data-urlencode 'q=*' \
  --data-urlencode 'filter_by=location:(31.5,-97.2,30 mi)' \
  --data-urlencode 'per_page=1'
GEO_FOUND_NOCUT=${FOUND}
GEO_TIME_NOCUT=${TIME_MS}

probe "4. geo radius 30 mi, search_cutoff_ms=${CUTOFF_MS}" -G "${TYPESENSE_HOST}/collections/places/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  --data-urlencode 'q=*' \
  --data-urlencode 'filter_by=location:(31.5,-97.2,30 mi)' \
  --data-urlencode 'per_page=1' \
  --data-urlencode "search_cutoff_ms=${CUTOFF_MS}"
GEO_FOUND_CUT=${FOUND}
GEO_TIME_CUT=${TIME_MS}

echo ""
echo "=== WORKAROUND: exact_filter_radius skips the unbounded loop ==="

probe "5. geo radius 30 mi, exact_filter_radius 1 mi" -G "${TYPESENSE_HOST}/collections/places/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
  --data-urlencode 'q=*' \
  --data-urlencode 'filter_by=location:([31.5,-97.2], radius: 30 mi, exact_filter_radius: 1 mi)' \
  --data-urlencode 'per_page=1'
EFR_TIME=${TIME_MS}

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

echo ""
echo "=== Summary ==="
echo "control non-geo : found ${CTL_FOUND_NOCUT} -> ${CTL_FOUND_CUT}   time ${CTL_TIME_NOCUT}ms -> ${CTL_TIME_CUT}ms   (cutoff honoured)"
echo "geo radius      : found ${GEO_FOUND_NOCUT} -> ${GEO_FOUND_CUT}   time ${GEO_TIME_NOCUT}ms -> ${GEO_TIME_CUT}ms   (cutoff ignored)"
echo "exact_filter_radius: ${EFR_TIME}ms"

FAIL=0

# Control must actually be curtailed: fewer results when the cutoff is applied.
if [ "${CTL_FOUND_CUT}" = "NA" ] || [ "${CTL_FOUND_NOCUT}" = "NA" ]; then
  echo "UNEXPECTED: control query did not return a 'found' value"
  FAIL=1
elif [ "${CTL_FOUND_CUT}" -ge "${CTL_FOUND_NOCUT}" ]; then
  echo "UNEXPECTED: search_cutoff_ms did not curtail the non-geo control query"
  FAIL=1
fi

# Geo filter must be completely unaffected: identical result set ...
if [ "${GEO_FOUND_CUT}" = "NA" ] || [ "${GEO_FOUND_NOCUT}" = "NA" ]; then
  echo "UNEXPECTED: geo query did not return a 'found' value"
  FAIL=1
elif [ "${GEO_FOUND_CUT}" -ne "${GEO_FOUND_NOCUT}" ]; then
  echo "NOT REPRODUCED: geo result set changed under search_cutoff_ms"
  FAIL=1
fi

# ... and must blow through the deadline.
if [ "${GEO_TIME_CUT}" = "NA" ] || [ "${GEO_TIME_CUT}" -le "${CUTOFF_MS}" ]; then
  echo "NOT REPRODUCED: geo query respected the ${CUTOFF_MS}ms deadline"
  FAIL=1
fi

echo ""
if [ ${FAIL} -eq 0 ]; then
  OVER=$((GEO_TIME_CUT / CUTOFF_MS))
  echo "BUG REPRODUCED"
  echo "  search_cutoff_ms=${CUTOFF_MS} curtailed the non-geo query (${CTL_FOUND_NOCUT} -> ${CTL_FOUND_CUT} results)"
  echo "  but did NOT curtail the geo radius filter: ${GEO_TIME_CUT}ms is ~${OVER}x the deadline"
  echo "  and it still returned the complete result set (${GEO_FOUND_CUT} results)"
  exit 0
else
  echo "Reproducer did not hit the expected state"
  exit 1
fi

Run against the released versions too:

VERSION=30.2 bash reproduce.sh
VERSION=29.1 bash reproduce.sh

Expected vs Actual

Expected: with search_cutoff_ms=20, the geo query returns in roughly the budget, sets "search_cutoff": true, and returns a partial found. The same way the non-geo control does.

Actual, on 31.0.rc13, 1,000,000 documents, search_cutoff_ms=20:

=== CONTROL: non-geo query, search_cutoff_ms=20 is honoured ===
1. text+facet, no cutoff                       found=1000000    search_time_ms=176    search_cutoff=false
2. text+facet, search_cutoff_ms=20             found=131071     search_time_ms=24     search_cutoff=true

=== BUG: geopoint radius filter ignores search_cutoff_ms=20 ===
3. geo radius 30 mi, no cutoff                 found=692602     search_time_ms=345    search_cutoff=false
4. geo radius 30 mi, search_cutoff_ms=20       found=692602     search_time_ms=309    search_cutoff=true

=== WORKAROUND: exact_filter_radius skips the unbounded loop ===
5. geo radius 30 mi, exact_filter_radius 1 mi  found=1000000    search_time_ms=49     search_cutoff=false

The control drops from 1,000,000 to 131,071 results and from 176 ms to 24 ms. Work was really abandoned.

The geo query returns an identical 692,602 with and without the cutoff, at 345 ms vs 309 ms. No work was abandoned. "search_cutoff": true on line 4 is set after the fact and is misleading.

A second run on the same image reproduced this identically: geo 692,602 -> 692,602 at 310 ms -> 341 ms, i.e. ~17x the deadline.

Same behaviour on the released versions, same script and dataset:

Version geo, no cutoff geo, search_cutoff_ms=20 over deadline
31.0.rc13 345 ms 309 ms ~15x
30.2 294 ms 263 ms ~13x
29.1 151 ms 127 ms ~6x

In every case found is unchanged by the cutoff, so this is not a regression and it is not yet fixed on the 31 line.

Environment

  • Typesense 31.0.rc13, Docker typesense/typesense:31.0.rc13 (digest sha256:5e213951a456162cabb6b9fc7d43ffb02f543c251110b90a5fb01576e9ede021)
  • Also reproduced on typesense/typesense:30.2 and typesense/typesense:29.1
  • curl only, no client library

Schema / Configuration

{
  "name": "places",
  "fields": [
    {"name": "title", "type": "string"},
    {"name": "kind", "type": "string", "facet": true},
    {"name": "location", "type": "geopoint"}
  ]
}

Additional Context

Root cause in src/filter_result_iterator.cpp. Line numbers below are at be2c5cc1 on the v31 branch. There is no v31.0.rc13 tag and the binary prints only Typesense 31.0.rc13 with no git hash, so the exact build commit cannot be read back from the image; be2c5cc1 is the newest v31 commit predating the rc13 image push. This does not affect the citation, since the file is byte-identical from be2c5cc1 through the current v31 tip 2b1ea800.

  • filter_result_iterator_t::init() begins at line 1137. All the geo work below runs inside it, during filter tree construction.
  • Line 1898: geo_range_index->search_geopoints(cell_ids, geo_result_ids) materialises the coarse S2 cell candidate set.
  • Lines 1900 to 1910: the exact_filter_radius escape hatch. A query radius above EXACT_GEO_FILTER_RADIUS_KEY skips the exact pass via continue.
  • Lines 1917 to 1925: the exact distance pass for a single geopoint. It walks every candidate id, unpacks lat/lng from the sort index and calls query_region->Contains(). Lines 1930 to 1950 are the same pass for geopoint arrays.

Why the candidate set gets large, in src/numeric_range_trie.cpp:

  • get_max_search_level at line 570 masks off trailing zero bytes of the cell id. A coarse S2 cell has many trailing zero bytes, so the search level returned is small.
  • search_geopoints_helper at line 583 descends only to that level and then takes the whole node. A shallow node covers a large area, so ids_t::uncompress at line 613 materialises a very large posting list, which is timsorted at line 616.

The timeout machinery exists in the file but does not cover any of the above. The is_timed_out() call sites are at lines 444, 526, 998, 2474, 2688, 3036, 3079, 3167, 3192, 3242, 3318, 3354, 3472, 3871 and 3925. The two that bracket the geo work are line 998, inside next(), and line 2474, inside is_valid(); both are on the iteration path. Nothing between 998 and 2474 checks the deadline, and the whole geo compute region sits inside that gap. Because init() computes the complete filter result before iteration starts, the deadline is structurally unreachable during the expensive phase.

The same gap was verified independently at v29.1 (search_geopoints at line 1554, is_timed_out() at 755, 2057, 2268, 2731) and at v30.2 (search_geopoints at line 1621, is_timed_out() at 812, 2126, 2340, 2790). The file differs materially between those tags, so each was checked rather than assumed.

Workaround

Set exact_filter_radius below the query radius, which takes the escape hatch and skips the distance loop:

filter_by=location:([31.5,-97.2], radius: 30 mi, exact_filter_radius: 1 mi)

49 ms instead of 345 ms on 31.0.rc13 in this reproducer. The trade off is that results become the union of coarse S2 cells rather than the circle, so they are over-inclusive at the edges, and applications needing exact edges must re-check true distance on the returned page. search_cutoff_ms is not a workaround, which is the point of this issue.

Related

#2952 reports the same structural gap reached through a different filter type: a non-selective numeric range_index clause is also fully materialised during the filter build phase, where search_cutoff_ms cannot reach it. That issue's primary symptom is per-request memory and OOM under concurrency; this one's is unbounded wall time on the geo path, with a "search_cutoff": true flag that claims a curtailment that did not happen.

Taken together they suggest the fix belongs at the filter build phase generally, rather than in either filter type specifically.

Suggested Fix

Pass the deadline into the geo candidate loop and the exact distance pass, and abort with search_cutoff set, the same way the iteration path already does.

More generally: filter_result_iterator_t::init() computes a complete filter result with no deadline check anywhere in it. A check inside the materialisation loops, not only on the iteration path, would cover both this and #2952.

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