← PRA / report example

Independent demo based on public GitHub data. PRA is not affiliated with or endorsed by Typesense, Inc.

Typesense, technical review for October 2026

Scope and sources

Repository typesense/typesense, branch v31, window [2026-10-01T00:00:00Z, 2026-11-01T00:00:00Z).

Changes were selected by merge date, taken from the committer date of the commit in the v31 history and checked against the pull request it closes. Author dates of branch commits were not used for selection.

Tests were read in the diffs, not run. Where a pull request author or a commit message reports a measurement, this review says so and names the source. Code links are pinned to the merge revision, so behaviour on current v31 may differ.

1. A key carries an explicit policy for the collections it joins

Before: a join lets a search read fields of another collection through a reference, written as $authors(country:=US) in filter_by and $authors(name,country) in include_fields. An API key described which collections it could act on and with which actions, and said nothing about joins. A key restricted to books could therefore reach fields of any collection that books referenced.

After: api_key_t gains a joins member. It is nullptr when absent, which preserves unrestricted join access for keys created before this change, and otherwise an object mapping a collection name to allowed_fields and denied_fields. An empty allowed_fields allows all fields of that collection, an empty denied_fields denies none, and denied_fields wins over allowed_fields. A collection absent from the object cannot be joined at all, and "joins": {} therefore prohibits every join the request asks for or that natural language search generates. Naming a field outside the policy returns 403. Wildcard and implicit joined projections are intersected with the allowance rather than rejected.

The policy travels in a reserved embedded parameter, __typesense_join_policy. AuthManager::authenticate sets it for documents:search, documents:get and documents:export. authenticate_parse_params erases whatever a scoped key’s payload carried under that name before setting it from the root key, so a scoped key cannot forge or broaden the policy it inherits. validate_field_names=false does not disable the check, and PATCH /keys/:id with "joins": null is the one way to remove a policy, distinct from omitting the field, which preserves it. api_key_t::validate rejects a non-object joins, an entry whose value is not an object, an unknown property inside an entry, and a non-array or non-string field list, each with its own message.

#3125 follows a day later and changes no externally visible behaviour. Join policy filter validation no longer resolves document ids, and the resolved policy is cached for the duration of one search instead of being rebuilt per joined field.

Pull requests: #3119, #3125.

Code: 323df78, a9d9b69, include/auth_manager.h, include/field.h, src/auth_manager.cpp, src/collection_manager.cpp, src/core_api.cpp, src/join.cpp.

Tests read: AuthManagerTest.JoinPolicyIsPersistedAndInheritedByScopedKeys, .ValidatesJoinPolicyShape, .PatchKeyCanRemoveJoinPolicy, CollectionJoinTest.APIKeyJoinFieldPolicy, .ScopedAPIKeyJoinPolicyIntegrationTest, .APIKeyJoinPolicyRejectsNestedFacets, .APIKeyJoinPolicyPreservesWildcardFacetMetadata, .APIKeyJoinPolicyAppliesAfterCurations, CoreAPIUtilsTest.JoinPolicyAppliesToExportAndDocumentGet.

Limits: the pull request lists the cases it intends to cover and the tests follow that list. This review read the diff and did not attempt an independent search for a path that reaches a joined field without passing through enforce_join_policy. Documents written through a key are a separate path and are not covered here, see the work in progress section.

2. A scoped key’s filter cannot be widened by the request

Before: AuthManager::add_item_to_params appends a scoped key’s embedded filter_by to the request’s own. When a regular expression decided that both strings looked like a join, Join::merge_join_conditions merged them instead, and the comment in the code accepted that the expression had false positives. Separately, nothing required the request’s filter to be a complete expression, so an unclosed backtick in it could swallow the embedded filter appended after it.

After: the merge path and the regular expression are gone. The request’s filter is tokenized inside parentheses and rejected unless the token stream terminates on its own, and the embedded filter is then always joined with &&.

Pull request: #3111.

Code: b55704d, src/auth_manager.cpp.

Tests read: AuthManagerTest.AppendsEmbeddedJoinFilterWithoutMerging, .RejectsUnterminatedFilterBeforeAppendingEmbeddedFilter, CoreAPIUtilsTest.ScopedJoinFilterCannotBeBypassedWithOr, .SearchCacheShouldRespectScopedEmbeddedFilters.

Limits: removing the merge changes behaviour for a request that joins the same collection as its key. Such a request now carries two join conditions combined with && rather than one merged condition. The pull request describes itself as a refactor and does not say which existing requests change shape.

3. Document writes stop waiting behind searches and compactions

Before: each collection is guarded by a std::shared_mutex. That lock admits a reader while a writer waits, so overlapping searches could keep a document write out of the lock for as long as they kept arriving. Separately, Store::delete_range took the store mutex exclusively, which made it wait for any compact_all() in progress, and the batched indexer calls delete_range after every write request, so writes stopped for the length of a compaction.

After: a collection has a second lock, write_turnstile. A document writer holds it from the moment it begins waiting for mutex until it has it, and a search passes through it before taking its first shared lock, so a search that starts while a write is waiting queues behind that write. The lock order is alter_mutex, then write_turnstile, then mutex. Nested shared locks taken during a join or a reference do not pass the turnstile, because one that waited for a writer while its thread already held another collection’s lock could deadlock against a join in the other direction.

The hold is bounded and configurable. write-priority-max-percentage takes an integer from 0 to 100 and defaults to 25. It can be set on the command line, in the [server] section of the ini file, through TYPESENSE_WRITE_PRIORITY_MAX_PERCENTAGE, or at runtime through update_config. A writer holds the turnstile for the setting multiplied by 20 ms, clamped to between 500 ms and 2 s, which is 500 ms at the default. After a hold of length T no writer may close the turnstile again for T * (100 - p) / p, three times T at the default, so back to back writes cannot gate searches continuously. A writer that still has not taken mutex when its hold expires logs a warning, at most once a minute per collection, and goes on waiting with the turnstile open. Setting the value to 0 removes the turnstile entirely and restores the previous behaviour.

Store::delete_range now takes the store mutex shared. The commit states the reasoning: the mutex guards replacement of the database handle, and RocksDB is itself thread safe.

Code: 5def5b4, 7567c57, 264111e, ccebdb0, c3fec78, 6e411f2, c2f90d0, f15f778, include/collection.h, include/tsconfig.h, src/collection.cpp, src/tsconfig.cpp, src/store.cpp.

Tests read: CollectionSpecificMoreTest.SearchStartedWhileImportWaitsRunsAfterTheImport, .SearchStartedWhileDeleteWaitsRunsAfterTheDelete, .SearchStartedWhileDeleteByFilterWaitsRunsAfterTheDelete, .JoinSearchIsNotHeldBackByAWriteWaitingOnTheJoinedCollection, .WriteStarvedByUngatedReadersHoldsBackSearchesOnlyBriefly, .WriteAfterAWriterGaveUpDoesNotHoldSearchesBackAgain, .AfterAWriteHoldsTheGateItStaysOpenLongerThanItWasClosed, .AWriteAtHalfPriorityHoldsSearchesBackForASecond, .ALowWritePriorityStillHoldsSearchesBackForHalfASecond, .SearchStartedWhileImportWaitsRunsFirstWhenWritePriorityIsOff, and the configuration cases in tsconfig_test.cpp.

Limits: the tests assert ordering and timing with waits inside one process. The sources checked hold no measurement of search latency or write throughput on a loaded server, and nothing explains how the default of 25 was chosen. The delete_range commit gives no measurement of the stall it removes.

4. A cascade delete and a joining search both finish

Before: cascade deletion locked the parent collection exclusively and then recursively locked every referencing collection exclusively, holding each one as it went. A search held its own collection shared for the whole index search and took a referenced collection shared during join filtering. The two orders form a cycle, and neither a search cutoff nor an HTTP timeout can break it, because both threads are blocked inside shared_mutex acquisition rather than inside any Typesense loop. Issue #3086 reports it.

After: the cascade tree is flattened into a list of deferred locks. The deleter blocks on one lock while holding none of the others, then takes the rest with try_lock. If any of them is contended it releases the whole set and blocks on the contended lock in the next round. A join that already holds that collection can then take one of the released locks and finish.

Pull request: #3091.

Code: 4f8f4a2, include/collection_manager.h, src/collection_manager.cpp.

Tests read: CollectionJoinTest.CascadeDeleteDoesNotHoldParentWhileWaitingForReferencingCollection, and the existing .CascadeDeletion and .CascadeDeleteOption were adjusted. The new test drives the race through two hooks compiled only into the test build, collection_manager_after_cascade_first_lock and collection_manager_after_cascade_retry_release.

Limits: releasing and retrying removes the cycle and admits repeated rounds. Nothing in the diff bounds how many rounds a delete may take under continuous join traffic, and the sources checked contain no measurement of cascade delete latency under load.

5. A crashing housekeeper no longer leaves a process answering health checks

Before: the housekeeper logs long running queries. It read each in-flight request’s req->params map directly while the search thread was free to mutate it, and multi_search resets that map for every search it runs. Under enough load the housekeeper crashed inside _Rb_tree_increment. The crash handler then tried to dump the queries in flight, which meant taking ifq_mutex, already held by the thread that had just crashed. The process stopped there with the mutex held: every search blocked in add_req(), the Raft loop stalled, and /health went on answering ok.

After: add_req() builds the query log line on the request’s own thread and caps the body at 16 KB, and the housekeeper logs that snapshot rather than reading the live map. The crash handler skips the dump when the crashing thread holds ifq_mutex and waits at most one second for it otherwise. A 60 second SIGALRM watchdog is armed inside the signal handler, so a crash handler that hangs anyway can no longer keep a wedged process alive.

Code: 86a3337.

Tests read: none. The commit adds no test, and it is a direct commit with no pull request, so there is no review discussion to read either.

Limits: the three parts address the crash, the deadlock and the hang separately. Nothing in the sources checked shows how often the original crash occurred or on what load.

6. A compressible import body inflates into a bounded buffer

Before: ReplicationState::handle_gzip sized its output buffer at ten times the compressed chunk. Once that buffer filled, the loop called inflate() again with next_out already at the end of the allocation and avail_out reset to the full buffer size, so zlib wrote past it. Any gzip encoded write body compressing better than about ten to one, which a repetitive JSONL import reaches easily, could corrupt the heap inside inflate() or lose documents silently.

After: inflation goes through a fixed 64 KiB stack buffer and appends to the output, so the output size no longer depends on the compressed size. The loop uses Z_NO_FLUSH, since one gzip stream can span several request chunks, and a stream still incomplete after the last chunk returns Incomplete gzip stream. instead of passing on a truncated body. A null zs.msg is tolerated and zstream_initialized is reset after inflateEnd().

This change is the September commit f0c6f9ea, written on 24 September, which had no pull request at the end of that month and was listed there among the branches with no open pull request. It was opened as #3117 on 14 October and merged on 28 October.

Pull request: #3117.

Code: f1cfbab, src/raft_server.cpp.

Tests read: RaftServerTest.DecompressesHighlyCompressibleGzipAcrossChunks, which compresses 1 MiB of a repeated byte and feeds it in two chunks, plus the added truncated stream and empty last chunk cases.

Limits: the fix is in the HTTP write path that Raft replication shares. This review did not check for other callers that size a buffer from a compressed length.

7. A node starts from its own cluster state when a peer does not resolve

Before: hostname2ipstr returned the original hostname when getaddrinfo failed, and resolve_node_hosts skipped a node it could not resolve and returned whatever it had. A partial peer list could therefore be applied, which removes valid peers from the cluster, or a literal hostname could be parsed as an address. A starting node gave up after three parse attempts and returned an error.

After: a failed resolution returns an empty string, and resolve_node_hosts returns an empty configuration as soon as any host fails, so a partial list is never applied. At startup, a node that already has a Raft log or a completed snapshot starts from the membership recorded there, because braft uses initial_conf only when there is no persisted state, and the periodic peer refresh applies the configuration once every host resolves. A node with no persisted state waits in ten second rounds and re-reads its nodes file between them, so a correction made while it waits is picked up. refresh_nodes and reset_peers now refuse to act on a configuration that does not parse or is empty. Snapshot detection changed from counting directory entries to has_completed_snapshot, which ignores the temp directory of an unfinished snapshot that braft deletes on init.

The same commit closes a gap in the proxy endpoint. POST /proxy and its server-sent-event variant called APIAcl::is_allowed with the peer allowlist, and an empty allowlist allowed every source address. Peer addresses are not known until all nodes resolve, so under proxy-allow-only-peer-src-ips the window in which nothing had resolved was also a window in which nothing was restricted. is_allowed takes a require_src_ip_match flag, passed as that setting, and denies on an empty allowlist.

Pull request: #3103.

Code: 58e513b, include/api_acl.h, include/raft_server.h, src/core_api.cpp, src/raft_server.cpp, src/typesense_server_utils.cpp.

Tests read: APIAclTest.DeniesWhenAllowedSourceIpsEmptyAndMatchRequired, .DisallowsWhenDisallowedCidrsEmptyWhenSrcNotAllowed, and the additions to raft_server_test.cpp.

Limits: the startup and refresh paths are exercised through unit tests of their helpers. The sources checked contain no multi-node run with a failing resolver. An earlier pull request on the same subject, #3100, was closed without merging on 8 October, one day before this one merged, and the reason is not recorded in the data checked.

8. The test suite runs in eight shards, and CI reuses what it built

This section covers fifty-six commits: the direct commits of 23, 25 and 26 October, the test timing commit of 24 October, and the merge of #3097 on 5 October. Every measurement below is quoted from the commit message that reports it, made by its author and not reproduced here.

Before: the C++ suite ran in a single process, and tests wrote to fixed paths under /tmp/typesense_test, so two test processes at once overwrote each other’s data. The ONNX Runtime build was capped at three parallel jobs by a constraint dating from an older commit. Pull requests from forks get no repository variables, so each one missed the default branch’s Bazel cache and compiled every dependency, which the commit message puts at 67 to 87 minutes for a first run. Each run downloaded about 5.8 GB of models again, because the Bazel test temporary directory starts empty. The API tests ran their groups one after another with fixed sleeps between steps.

After, on the build side: tests keep their state under TEST_TMPDIR when Bazel sets it, and the suite runs in eight parallel shards, where the commit message reports the slowest shard at 52 seconds against 316 seconds for the whole suite in one process on 32 x86 cores. Lifting the three job cap on ONNX Runtime takes a cold build of the server from 1,062 seconds to 336 seconds on the same machine. The default branch’s Bazel disk cache is shared with every pull request, keyed on the dependency files, refreshed on every push to the default branch, and a run that only reads the cache may start from the previous week’s. ONNX Runtime’s submodules are pinned to commits so its build is cacheable, and its source tree is keyed on its build file and patch. Downloaded models are kept between runs, one directory per shard. A build with nothing cached moves to a larger runner named by COLD_RUNNER, and the probe that decides this is skipped when no such runner is configured. A pull request within the repository runs the suite once, through its branch’s push run, and push builds are limited to version branches.

After, on the API tests: the single node, multi node and standalone groups run concurrently, each standalone file in its own process, and the server is built in its own job so the tests start sooner. Fixed sleeps gave way to conditions: waiting for the voted leader, for a restarted server to finish replaying its log, for analytics to drain, and polling multi node synchronisation every 50 ms with a failure when it never converges. The job is bounded in time and uploads server logs on failure.

Two changes concern what CI exposes rather than what it costs. #3097 adds permissions: contents: read, actions: read to the workflows, pins every action to a commit with its version in a comment, sets persist-credentials: false on checkout, and replaces shell interpolation of github context values with environment variables. Separately, the step that uploads server logs after a failed API test now scrubs the provider API keys out of those logs first and fails the run if a key is still present, with the upload made conditional on the scrub having succeeded. The commit states the reason plainly: the server logs request payloads, provider keys included, and anyone who can read the repository can download its artifacts.

The group also contains one commit that updates the model name used by the conversation API test, one that tightens a nested join test’s wait, and two commits, c511155004 and 90ee58bb09, that change no files.

Code: 0d0e440, 5b178f0, 538be73, 6ab2e9c, fb73129, d3eb505, 0fe452f, 8861a79, .github/workflows/tests.yml, .github/workflows/benchmark-testing.yml, api_tests/, test/.

Tests read: this section is the test and build infrastructure itself. The assertions added are inside the API test harness.

Limits: the figures are the author’s and come from commit messages rather than from a published benchmark run, and none of them states the runner image or the cache state beyond what is quoted. A faster suite is not evidence that the suite covers more. These commits went into v31 directly, so there is no pull request review to read.

9. Union searches report their own timeout

Before: each sub-search of a union clears the thread-local search_cutoff flag before it runs, and do_union read that flag once at the end, so the value belonged to whichever sub-search happened to run last. A sub-search that exhausted the time budget went unreported, and one that found nothing its filter admitted returned before reading the clock at all. The same flag gates the 408 a union returns when the budget left it nothing, and CollectionManager::do_union returned result_op, which is always ok at that point, so the handler never saw the timeout. A timed out union answered 200 with an empty body. The handler’s 408 branch for a union had therefore never run and neither marked the response final nor streamed it.

After: the flag is accumulated across sub-searches, the 408 is returned from CollectionManager::do_union, and the handler’s union branch marks the response final and streams it.

This pull request was open at the end of September with four commits and was listed in the September review as work in progress. It merged on 6 October.

Pull request: #3052.

Code: d780448, include/http_data.h, src/collection.cpp, src/collection_manager.cpp, src/core_api.cpp.

Tests read: UnionTest.SearchCutoffCoversEverySubSearch was changed to expect the 408, CoreAPIUtilsTest.UnionSearchCutoffWithNothingToReturnIsRequestTimeout is new, and three cases were added to the HTTP level suite in api_tests/tests/union_search_cutoff.test.ts.

Limits: a cut off union now answers 408 where it previously answered 200 with an empty body, which is a deliberate change of response for an existing condition.

10. Natural language models survive a restart and reach current OpenAI models

Before: NaturalLanguageSearchModelManager::init validated every stored model with a live call to its provider when the server started. A provider outage or a revoked key during a restart dropped the model from the manager, after which PUT and DELETE on it answered 404. Separately, the parameter that bounds a completion was chosen by model name, and the newer OpenAI chat models reject max_tokens and require max_completion_tokens, so a configured GPT-5 or later model failed. Issue #3067 reports that case.

After: init passes a skip_api_call flag through validate_model into each namespace validator, the same approach #3058 took for embedding fields in September. Every static check still runs at init, including parsing a GCP service account key, and a stored model that fails one is still skipped. Creation and update still validate with a live call. For the parameter, every openai/ model whose api_url is unset or resolves to api.openai.com now gets max_completion_tokens, because that host accepts it on every chat model. Models reached through a custom api_url and azure/ models keep the name based rule, widened to a single digit o<digit> token at a word boundary and to a digit of 5 or more directly after gpt-, so gpt-6-luna moves while gpt-41-mini, gpt-4o and EleutherAI/gpt-j-6b stay on max_tokens. A fine-tune id is classified by its base model. vllm/ sends max_tokens and temperature in validation as well as in search, and a configured temperature is no longer rejected by model name.

#3073 was open at the end of September with one commit and was listed there as work in progress. It merged on 30 October, and #3153 is its follow-up on the same day.

Pull requests: #3073, #3153.

Code: be4d9c5, 9ff8fb0, include/natural_language_search_model.h, include/natural_language_search_model_manager.h, src/natural_language_search_model.cpp, src/natural_language_search_model_manager.cpp.

Tests read: NaturalLanguageSearchModelManagerTest.InitLoadsStoredModelsWithoutApiCall, .InitSkipsStoredModelWithInvalidConfig, .InitReloadsStoredModelsWithProviderParams, NaturalLanguageSearchModelTest.OpenAIHostAlwaysUsesMaxCompletionTokens, .OpenAICustomUrlKeepsMaxTokens, .AzureGpt6RequestUsesMaxCompletionTokens, .AzureProdO3MiniUsesMaxCompletionTokens, .ReasoningModelNameRule, .OpenAIApiHostRule, .VllmSearchSendsValidationParams.

Limits: the name based rule is a heuristic over model names, and the tests fix the classification for the names they list. A provider that changes which parameter it accepts, or a name outside those patterns, is not covered. No call to a live provider was made in preparing this review.

11. The memory guard retries after returning jemalloc pages

Before: the write guard rejects a write with 422 OUT_OF_MEMORY when measured memory is above its limit. jemalloc can retain freed pages after a bulk delete, so measured memory stayed high and writes kept failing although the documents were gone. Issue #2926 reports it.

After: when the guard would reject a write, it asks jemalloc to purge unused dirty pages and rechecks the operating system or cgroup reading. The write is still rejected if memory remains high or the purge fails. The purges themselves moved into housekeeping rather than running on the request path, and the metrics endpoint gained allocator diagnostics: typesense_jemalloc_dirty_pages, typesense_jemalloc_page_size, the per-arena dirty_decay_ms and muzzy_decay_ms, the background thread run count and a read error counter.

The pull request reports that in an isolated 1.5 GiB container a 422 was confirmed under sustained pressure, and that after deleting 312,345 documents the next write returned 201. Those are the author’s figures.

This pull request was open at the end of September with one commit and was listed there as work in progress. It merged on 6 October.

Pull request: #3080.

Code: 60ae5e2, include/cached_resource_stat.h, include/housekeeper.h, include/system_metrics.h, src/cached_resource_stat.cpp, src/housekeeper.cpp, src/system_metrics.cpp.

Tests read: SystemMetricsTest.ResidentMemoryIncludesActivePagesAndAllocatorOverhead, excluded from AddressSanitizer builds, with the jemalloc read error assertion restricted to Linux.

Limits: the container measurement is the author’s and was not reproduced. A purge costs time on the path that triggers it, and the sources checked do not measure that cost.

12. Pinned hits survive a filter that matches fewer documents

Before: Index::search sizes its result holder from the larger of the group candidate limit, the fetch size, a default and the number of pinned ids, then clamps that down to the approximate number of documents the filter admits. The holder for curated results was constructed with the same clamped size, so a filter_by admitting fewer documents than there were pinned hits dropped the pins beyond that count. Reported as issue #3120.

After: the curated holder is sized at the larger of that value and the number of pinned ids, so every requested pin has room. Pin filtering and grouping are unchanged.

Pull request: #3124.

Code: a4d3d35, src/index.cpp.

Tests read: CollectionCurationTest.PinnedHitsSurviveSmallFilterResults, with one and two filter matches, grouped and ungrouped.

Limits: the fix enlarges one allocation per search that uses pinned hits. The pull request does not measure the memory effect of a very large pin list.

13. A patched object array leaves no stale index behind

Before: nested object arrays are flattened into child fields for indexing. Replacing or deleting such a field left the flattened child values in the in-memory index, so after patching {"reviewQueue": []} a filter like reviewQueue.reviewerId:=9001 still matched the document although the stored array was empty.

After: the update diff carries the full parent path through recursion and uses the root .flat metadata and the root level deletion output to remove flattened children missing from the update, including arrays nested inside objects such as wrapper.reviewQueue. Children supplied in the update go through the existing diff logic, so changing one child while retaining another keeps the document searchable by the retained one under both update and upsert. Child scans are restricted to object parents and object arrays.

Pull request: #2920.

Code: f66a7c1, src/index.cpp.

Tests read: CollectionNestedFieldsTest.UpdateNestedObjectArrayRemovesChildIndexes, .UpdateDeepNestedObjectArrayRemovesChildIndexes, .UpdateNestedObjectArrayPreservesUnchangedChildIndexes, .IndependentDottedFieldSurvivesParentArrayReplacement, .NestedArrayRemovalTracksMissingImmediately, .NullObjectParentTracksMissingImmediately, .FirstNullObjectArrayRemoval. The pull request states that the two added regressions failed before the fix and passed after it.

Limits: the stale entries were in the in-memory index, and documents already written with them are corrected by a further update rather than retroactively. The pull request does not describe a repair path for an existing index.

14. Curation rules keep their filter through stopwords and stemming

Before: a curation rule can carry a filter_by applied when its trigger matches the query. The filter pass re-matched a static rule against the normalized query, and that match failed when the request’s stopword set had removed a trigger token, so shoes for men lost its filter under a stopword set containing for. A stem: true rule compared literal tokens rather than stem equivalent ones. A rule whose replace_query had already rewritten the query could not re-match it either. Separately, a dynamic filter rule with a mismatched token count left the curation index out of step, so a later static rule read another rule’s normalized query.

After: the request’s stopwords are stripped from the rule tokens before the filter pass re-matches, stem rules match stem equivalent query tokens on fields with and without stemming and remove only the matched token, and a rule that rewrote the query applies its filter without re-matching and keeps the replacement verbatim. The index desync is fixed. Exclusion operators, phrase queries, field level symbols_to_index and token_separators, and chained stop_processing: false rules stay gated by the filter pass as before.

This pull request was opened on 24 July and supersedes #2969, which was closed without merging on 16 October.

Pull request: #2999.

Code: 2224f51, src/collection.cpp, src/index.cpp.

Tests read: fourteen cases in collection_curation_test.cpp, among them FilterByAppliedWhenTriggerQueryHasStopword, ContainsRuleStemmedMatchAppliesFilterBy, DictionaryStemRuleOnStemmingFieldMatchesRootForm, ContainsRuleReplaceQueryKeepsReplacementVerbatim, DynamicFilterRuleBeforeStaticRuleKeepsIndexInSync, ExclusionOperatorDoesNotApplyFilterBy, FieldLevelSymbolsGateFilterBy and ChainedFilterRulesConsumeTokensInOrder.

Limits: a rule that previously did not apply its filter now does, which changes results for any deployment relying on the old behaviour. The pull request names the cases that must keep not applying the filter and tests them.

15. Queries are truncated to a server limit before tokenization

Before: query length was bounded only by request size.

After: --max-query-len sets a maximum number of Unicode code points, with 0, the default, leaving the limit off. It can be set on the command line, through an environment variable or in the ini file, and it is server controlled, so a search parameter cannot raise it. The query is truncated on a valid UTF-8 boundary before tokenization, through the shared search path, so single, multi and union searches all get it, and voice transcripts as well. The existing response cache behaviour is preserved, with the truncated query as the key. Fixes issue #2980.

Pull request: #3108.

Code: 2c81e74, include/string_utils.h, include/tsconfig.h, src/collection.cpp, src/string_utils.cpp, src/tsconfig.cpp.

Tests read: CollectionTest.MaxQueryLengthTruncatesBeforeSearch, .MaxQueryLengthCannotBeOverriddenBySearchParameters, .MaxQueryLengthTruncatesVoiceTranscriptBeforeSearch, CoreAPIUtilsTest.MaxQueryLengthTruncatesSingleMultiAndUnionWithCache, UnionTest.MaxQueryLengthTruncatesEachMember, and the Unicode boundary cases in string_utils_test.cpp.

Limits: truncation is silent. The response carries no indication that the query was shortened, and the default leaves the behaviour unchanged for anyone who does not set the flag.

16. A negated join is computed from the parent collection

Before: Join::negate_left_join scanned every non-matching child document and then built parent and child pairs from them.

After: it uses the parent collection’s Index::seq_ids to compute the negated result set directly.

The pull request reports search_time_ms on an inventory dataset at five sizes. At one million documents a positive join took 45 ms, a negated join 54 ms, a negated join with no matches 33 ms and the inner filter alone 30 ms. At thirteen million the same four were 511, 755, 505 and 429 ms. These are the author’s figures, and the pull request gives no machine, build or concurrency conditions and no before and after pair, so the table describes the new code rather than the size of the improvement. Fixes issue #3131.

Pull request: #3133.

Code: b8c8a79, src/join.cpp.

Tests read: CollectionJoinTest.NegatedJoinsAndPreservesProductsWithoutReferences, .NegateLeftJoinScrambledReferences, and .FacetByReference was adjusted.

17. Collection metadata is written under an exclusive lock

Before: Collection::update_metadata took a shared lock and then assigned to metadata. PATCH /collections/:name with a metadata body therefore raced with every request that serializes the collection summary, and the pull request reports a segmentation fault under concurrent load.

After: the lock is exclusive. The change is one word.

Pull request: #3112.

Code: a2bb526, src/collection.cpp.

Tests read: none. The diff adds no test.

Limits: the fix is correct for this writer. This review did not audit other writers for the same mistake.

18. A GCP embedder is keyed by its region

Before: embedders are shared between fields through a key built from the model name, the project and the credentials, plus the number of dimensions. The region was not part of that key, so two fields configured for different GCP regions received the same embedder and therefore the same regional endpoint.

After: the key carries the region, with the default region used when a field does not name one, so a field without a region and a field naming the default region still share an embedder. This is part one of issue #3095.

Pull request: #3096.

Code: e9e9846, src/text_embedder_remote.cpp.

Tests read: CollectionManagerTest.GCPEmbedderIsKeyedByRegion, which uses init_collection so that no network call is made.

Limits: the commit originates on the branch of #3104, which also changes how embedders are held and evicted and was still open at the end of the month. Only the keying part merged here.

19. query_by and group_by honour disabled field validation

Before: reference style $-prefixed names are added to the resolved field lists before schema validation. With validate_field_names=false an unresolved name still returned 404 from query_by and remained in the resolved group_by fields, so disabling validation did not have the documented effect for those two parameters. Fixes issue #3135.

After: unresolved query fields are skipped and unresolved grouping fields are removed from the resolved vector, while field order, per-field query options and the errors raised under strict validation are preserved.

Pull request: #3141.

Code: 5d4da44, src/collection.cpp.

Tests read: CollectionSpecificMoreTest.IgnoreUnresolvedDollarQueryByFields, CollectionGroupingTest.SkipMissingReferenceStyleGroupFields.

20. The server starts under WSL

Before: brpc opens /proc/self/io while constructing its global bvar objects. WSL does not provide that file, so brpc called PLOG_ONCE(WARNING) before main() had run, with glog not yet initialised, and the process crashed on startup. Reported as issue #3090 against WSL 2.8 and later.

After: a patch applied to the vendored brpc includes sys/uio.h and replaces that warning with fprintf, which is safe before glog exists.

Pull request: #3093.

Code: b708744, the brpc patch under bazel/.

Tests read: none. The change is a build-time patch to a dependency.

Limits: the fix is in a patch file carried by this repository, so it holds until the vendored brpc commit moves. The pull request reports no WSL run in the data checked.

21. An empty object filter is rejected with its own message

Before: an object filter such as ingredients.{} produced no filter node. toParseTree returned early on that path, which left the nodes already on its stack unfreed, and errors from the object filter parser were reported as 400 whatever code they carried.

After: an empty object filter sets a 400 with the message Object filter cannot be empty., the loop breaks rather than returning, so the stack is unwound and freed, and a code raised by the parser is passed through.

Code: 66fec87, src/filter.cpp.

Tests read: FilterTest.ObjectFilterValidation, which runs eight shapes of empty object filter alone and in conjunction, disjunction and nesting.

22. Facet parsing stays inside the facet string

Before: Collection::parse_facet walked the facet string with an index of type int, and two of its inner loops were written as for (i; facet_field.size(); i++). The condition is the length of the string rather than a comparison against it, so it is true for any non-empty facet string and the loop reads past the end unless it meets a comma or a closing parenthesis first.

After: the index is a size_t and each loop tests i < facet_field.size() alongside its delimiter checks. The commit message calls this a clarity change. The reading past the end of the string is a consequence of the same rewrite, and the commit adds no test.

Code: ebfeb67, src/collection.cpp.

Tests read: none.

Limits: whether the old loops could be reached with a facet string that has no delimiter after sort_by or top_k was not established here, so the practical effect is not claimed.

23. The vector index is pinned to a newer hnswlib

The commit moves the pinned commit of the project’s own hnswlib fork from 687d9817 to 1c4befe8 and adds a comment in Index::repair_hnsw_index. The comment records that repair_m protects the index against being dropped but not against resizeIndex(), and that a resize can invalidate the level-0 pointers the repair uses once the index lock is released, because mremap unmaps the old address when the buffer moves. The commit message says the purpose is to avoid memory spikes during index growth. That change lives in the hnswlib repository and is not in this diff, so this review states the version move and not its effect.

Code: cdf732d, WORKSPACE, src/index.cpp.

Tests read: none.

Limits: the ingest’s diff for this commit contains only src/index.cpp. The WORKSPACE line was read from the commit on GitHub, and this gap is recorded in the internal coverage note.

Work in progress at the end of the period

This lists the branches that took commits in October and were not part of v31 on 31 October. Forty-three commits in all, across twenty-seven pull requests that were open at the cutoff.

Author dates are used, which say when a commit was written and not when it was pushed. Merges of v31 into a branch are left out, so an initiative shows fewer commits here than GitHub does. Branch state was restored retrospectively from git history and pull request metadata, so exact draft or approval status on 31 October cannot be attributed. None of this counts towards the month’s results, and the list is not an estimate of readiness, a confirmed roadmap or a delivery commitment.

Fourteen pull requests that were open at the end of September were still open at the end of October and took no October commits: #2320, #2878, #2993, #3038, #3039, #3041, #3043, #3045, #3063, #3064, #3066, #3075, #3076 and #3082. They are not listed below, because they have no October commits to list.

Ordered by the number of October commits.

#3104, Embedders held and evicted per field (3 commits)

Continues the theme of section 18. The branch keys GCP embedders by region, the commit that reached v31 as #3096, evicts a remote embedder when its last field is dropped, and refcounts embedders per field so they survive between requests. Opened 8 October.

#3109, UTF-8 across streamed import chunks (3 commits)

A multibyte character split across two request chunks. Opened 12 October.

#3123, dimensions sent to fixed-size embedding models (3 commits)

Also in the embedder path. The branch stops sending a dimensions parameter to models that do not accept one and retries without it when a provider rejects it. Opened 16 October.

#3146, Multibyte highlight offsets (3 commits)

Highlight end offsets on multibyte text, and prefix highlights around skipped symbols. Opened 28 October.

#3050, Document visibility during bulk import (2 commits)

Open since 15 September and described in the September review. In October the branch undoes the store write when a record fails while being indexed, and adds a test that pauses batch_index() between the store write and indexing.

#3114, Hybrid search parameters on the flat path (2 commits)

k, distance_threshold and hidden hits applied to a brute force vector search. Opened 13 October.

#3115, Dynamic field precedence (2 commits)

Declaration order when several dynamic field rules match, including after a schema alteration. Opened 14 October.

#3121, Stalled import responses blocking indexing (2 commits)

Opened 16 October.

#3122, Empty queries in no-hit analytics (2 commits)

Opened 16 October.

#3136, Non-string values from the model (2 commits)

Continues the theme of section 10. Search parameters generated by a model are not always strings. Opened 25 October.

#3139, Matching across stemmed and unstemmed fields (2 commits)

Opened 26 October.

#3142, Scoped presets and the result cache (2 commits)

Continues the theme of sections 1 and 2. A preset referenced by a scoped key is resolved before authorization, and the authorized collections become part of the search cache key. Opened 27 October.

#3068, A pending write after a failed replication (1 commit)

Open since 23 September and described in the September review.

#3072, The typed-judgment provider for natural-language search (1 commit)

Open since 24 September. It took fourteen commits in September and one in October.

#3088, Composite group_by folding two keys into one group (1 commit)

Opened 1 October around the September commit 53ebb424, which the September review listed among the branches with no open pull request at that cutoff.

#3106, Stale async references after a collection is recreated (1 commit)

Opened 8 October, on the same subject as the September branch #3082.

#3107, Backtick pairing in filter strings (1 commit)

Related to section 2, where an unterminated filter is rejected before an embedded filter is appended. Opened 9 October.

#3113, Filter before the vector part of a hybrid search (1 commit)

Opened 13 October.

#3116, Field tokenization in highlighting (1 commit)

Opened 14 October.

#3118, Custom stemming dictionary lookups (1 commit)

Opened 14 October.

#3128, API key authorization on single-document upsert (1 commit)

Reports an authorization gap on a write path, which section 1 does not cover. Opened 21 October.

#3129, Synonym proximity outside the match window (1 commit)

Opened 21 October.

#3132, Normalized Japanese query tokens (1 commit)

Opened 23 October.

#3138, vec_dist with num_dim of 0 (1 commit)

Opened 25 October.

#3145, Per-user prefix query entries after compaction (1 commit)

Opened 28 October.

#3150, curation_sets as a search parameter (1 commit)

Opened 29 October.

#3151, Cached searches in analytics (1 commit)

Opened 29 October.

Verification gaps left open

Ten pull requests were closed without merging during October. Three of them were named as a gap in the September review, and the dates are now established: #3089 on 6 October, #3081 on 26 October and #3053 on 27 October. The other seven are #3092, #3100, #2532, #2984, #2969, #3143 and #3101.

Every measurement in sections 3, 8, 11 and 16 is reported by its author. Reproducing any of them requires a build and is outside this review. The table in section 16 has no stated conditions and no before and after pair.

Everything in this review is bounded by the cutoff of 31 October. Later changes to any of it, including merges of the branches listed as work in progress, are not reflected here.

Privacy

Typesense is a trademark of Typesense, Inc.