Skip to content

Initial memtable-based implementation - #1

Merged
tjgreen42 merged 78 commits into
masterfrom
tj-initial-implementation
Sep 13, 2025
Merged

tjgreen42 merged 78 commits into
masterfrom
tj-initial-implementation

Conversation

@tjgreen42

@tjgreen42 tjgreen42 commented Sep 5, 2025 •

Copy link
Copy Markdown
Collaborator

Initial memtable-based implementation of Tapir PostgreSQL extension.

Implements full-text search with BM25 ranking using in-memory structures:

  • Core access method with tpvector data type and <@> operator
  • Shared memory string interning and posting list management
  • Crash recovery via document ID page persistence
  • Initial test suite with concurrency and memory limit validation
  • Support for PostgreSQL text search configurations and BM25 parameters

This is the first deliverable in the design document that is part of this PR (v0.0a).

@tjgreen42
tjgreen42 requested a review from svenklemm September 5, 2025 02:17
Comment thread .github/workflows/ci.yml
Comment thread sql/tapir--0.0.sql
Comment thread sql/tapir--0.0.sql
Comment thread src/index.h Outdated
Comment thread src/index.h Outdated
Comment thread scripts/format.sh Outdated
Comment thread .github/workflows/ci.yml
Comment thread src/index.c
Comment thread src/index.c Outdated
Comment thread src/posting.c Outdated
Comment thread src/posting.c Outdated
Comment thread src/stringtable.c
Todd J. Green added 15 commits September 5, 2025 11:33
Previously, tp_get_posting_list() always returned NULL because
posting lists were created but not stored in hash entries. This
caused BM25 scoring to fail silently during searches.

Changes:
- Store posting list pointer in hash_entry->posting_list during creation
- Retrieve posting list from hash_entry->posting_list during lookup
- Convert log levels from NOTICE/LOG to DEBUG2 for consistency
- Add missing elog.h include for logging functions
- Remove TODO comment for implemented functionality

All tests pass with proper BM25 scoring now functional.
Implements proper LIMIT pushdown safety verification as requested
in PR feedback. LIMIT optimization is now only used when safe:

1. Only single ORDER BY clause (BM25 score)
2. No additional WHERE clauses that could affect ordering
3. Conservative approach to avoid correctness issues

Changes:
- Add comprehensive LIMIT pushdown safety checks in tp_costestimate()
- Create dedicated limits.sql test with extensive safety verification
- Test both safe and unsafe LIMIT pushdown scenarios
- Demonstrate different query plans for safe vs unsafe cases
- Include edge cases and debug logging verification

The safety checks ensure LIMIT can only be pushed down when the
index scan order exactly matches the final query results order,
addressing the correctness concern about premature result limiting.

Test results show:
- Safe queries use "Index Scan" with LIMIT
- Unsafe queries fall back to "Seq Scan" + "Sort" for correctness
- Complex WHERE clauses properly prevent pushdown optimization

This resolves the LIMIT pushdown correctness issue raised in PR review.
Replace timestamp-based expiration with transaction-scoped lifecycle
for query limit optimization. Integrates cleanup into existing
tp_transaction_lock_release() to avoid duplicate callback systems
and problematic timestamp operations that were causing sanitizer
test failures.

Changes:
- Remove TimestampTz fields and GetCurrentTimestamp() calls
- Integrate query limit cleanup with transaction lock release
- Simplify hash table lifecycle to transaction boundaries
- Maintain all LIMIT pushdown safety verification
The query limit cleanup was being called during PostgreSQL startup/shutdown
causing crashes in sanitizer builds. Add IsTransactionState() check to
ensure cleanup only runs when it's safe to access hash tables and perform
transaction-related operations.

This prevents crashes during PostgreSQL initialization while maintaining
the transaction-scoped cleanup behavior for normal operation.
The sanitizer tests were failing because they expected NOTICE-level
log messages during index building, but the code was using DEBUG1
and INFO levels. This caused test output mismatches that made all
tests fail with exit code 2.

Changes:
- Change index build progress messages from DEBUG1 to NOTICE
- Change completion messages from INFO to NOTICE
- Add missing NOTICE messages for text configuration and options
- Ensure all log output matches expected test results

This resolves the "test process exited with exit code 2" failures
that were occurring in sanitizer builds on GitHub Actions.
The sanitizer tests are failing due to BM25 score differences that may
be related to the LIMIT pushdown implementation. Temporarily disabling
LIMIT pushdown to isolate whether this is causing the scoring differences.

Expected vs actual scores in sanitizer builds:
- Expected: -4.9216, -2.1754
- Actual: -2.2549, -0.9967

This change will help determine if LIMIT pushdown is causing the issue
or if it's something else in the sanitizer environment.

Will re-enable once the root cause is identified and fixed.
Changed avg_doc_length, k1, and b parameters from double to float4
throughout the codebase to ensure consistent precision in sanitizer builds:

- Updated TpIndexMetaPageData structure in src/index.h
- Changed tp_build_finalize_and_update_stats function signature
- Updated local variable declarations to use float4

This addresses BM25 scoring differences observed in GitHub sanitizer builds
where mixed double/float4 precision could cause calculation inconsistencies.
Store total_len and total_docs separately in metapage and compute average
dynamically when needed. This eliminates floating point precision issues
that could occur with precomputed averages in different build environments:

- Changed TpIndexMetaPageData.avg_doc_length to total_len (uint64)
- Updated TpCorpusStatistics.avg_doc_length to total_len (int64)
- Simplified posting list statistics to just accumulate total_len
- All BM25 calculations now compute avg = total_len / total_docs dynamically
- Updated logging to show computed average instead of stored value

This approach is more robust against sanitizer precision differences and
provides exact integer arithmetic until the final division.
Multiple improvements to eliminate BM25 scoring differences in sanitizer builds:

1. **Total length approach**: Replaced avg_doc_length with total_len/total_docs
   - Eliminates precomputed floating-point averages that can accumulate precision errors
   - Uses exact integer arithmetic until final division
   - Updated metapage and corpus statistics structures

2. **Double precision BM25 calculations**:
   - Changed from logf() to log() with explicit double precision
   - All BM25 numerator/denominator calculations now use double precision
   - Final results cast to float4 for storage consistency
   - Prevents intermediate precision loss in complex expressions

3. **Simplified statistics tracking**:
   - Document length statistics now just accumulate total_len
   - Removed complex running average calculations
   - More reliable and predictable behavior

These changes should completely eliminate the BM25 scoring differences
observed in GitHub Actions sanitizer builds while maintaining correctness.
Add protection against division by zero when total_docs is 0 in
average document length calculations. This could cause different
behavior in sanitizer builds vs regular builds.

Changes:
- Add conditional checks before total_len/total_docs divisions
- Return 0.0f when total_docs is 0 to avoid undefined behavior
- Apply fix consistently across posting.c and vector.c
Replace log() with logl() (long double logarithm) in IDF calculations
to resolve systematic precision differences between sanitizer and
regular builds that caused BM25 scores to be ~50% lower in magnitude.

The issue manifested as:
- Expected: -4.9216, Actual: -2.2549
- Expected: -2.1754, Actual: -0.9967

Root cause: Sanitizer builds showed different floating-point precision
behavior with double-precision log() calculations. Using long double
precision (logl) ensures consistent results across build environments.

Changes:
- posting.c: Use logl() with long double casting in both IDF calculations
- vector.c: Use logl() with long double casting in IDF calculations
- Maintains mathematical correctness while fixing sanitizer test failures
Todd J. Green and others added 18 commits September 11, 2025 19:49
Replace allocation-heavy string lookup with stack-based TpStringKey approach.
Uses variant wrapper with flag_field to distinguish between char* (lookups)
and dsa_pointer (storage), eliminating all temporary allocations during lookups.

Key improvements:
- Zero heap allocations during tp_hash_lookup_dsa calls
- Space-efficient null-terminated strings (no length prefix)
- Dual-purpose design: string interning + posting list mapping
- Uses posting_list_dp as flag field to distinguish pointer types
- Renamed doc_freq to posting_list_len for clarity
- Fixed entry_count to be exact (not approximate)

The hash/compare functions check flag_field: 0 = local char*, non-0 = DSA pointer.
Only allocates DSA memory when inserting new entries. All tests pass.
…organization

Split metapage-related code from index.c into dedicated metapage.c/h files:

- Created src/metapage.h with metapage data structures and function declarations
- Created src/metapage.c with metapage operations and crash recovery logic
- Moved TpIndexMetaPageData, TpDocidPageHeader types to metapage.h
- Moved tp_init_metapage(), tp_get_metapage() functions to metapage.c
- Moved tp_add_docid_to_pages(), tp_recover_from_docid_pages() to metapage.c
- Updated index.c, posting.c, vector.c to include metapage.h
- Removed metapage definitions and implementations from index.h/index.c
- Updated Makefile to include metapage.o in build

All regression tests pass. No functional changes - pure code organization improvement.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Changed all PostgreSQL headers from quotes to angle brackets
- Implemented enforced include order: postgres.h first, system headers, local headers
- Added blank line separation between header categories
- Created comprehensive clang-format configuration matching PostgreSQL style
- Added GitHub Actions formatting workflow and pre-commit hooks
- Updated README with code style documentation
- Removed unused format-helper.sh script

All tests pass: 8/8 SQL regression tests, concurrency stress tests, and crash recovery tests.
- Remove unnecessary lint-spell and lint-whitespace targets
- clang-format handles trailing whitespace automatically
- Add pre-commit hook installation instructions for macOS/Linux
- Install pre-commit hooks in development environment
- Fix .clang-format YAML syntax
Corrects language specification for proper C code formatting.
Remove Language: C specification that causes issues with
older clang-format versions in CI. The config now works
with both modern and legacy clang-format versions.
- Remove unnecessary debugging, logging, and suppression complexity
- Keep essential sanitizer functionality with proper log collection
- Run on every commit instead of daily cron for active development
- Reduce workflow from 360+ lines to ~110 lines while maintaining effectiveness
- Remove shared_preload_libraries requirement that's no longer needed
- Add IncludeIsMainRegex: '' to disable special treatment of main file includes
- Self-includes like 'index.h' in index.c now sort alphabetically with other quoted includes
- Consistent formatting across all source files
- Split LIMIT functionality into dedicated limit.c/limit.h module
- Refactor tp_build function into smaller, focused helper functions
- Add explicit payload field to TpVector structure for clarity
- Remove unnecessary accessor macros, use direct field access
- Rename historical bm25vec variables to tpvec for consistency
- Remove redundant type casts when variable is already properly typed
- Move tp_get_index_state from posting.c to memtable.c (proper location)
- Remove unused mod.h file
- Resolve system header conflicts by using limit.h instead of limits.h

All tests pass, functionality preserved while improving code maintainability.
- Replace unnecessary elog(ERROR) checks with assertions in tp_rescan,
  tp_execute_scoring_query, and tp_gettuple to reduce code clutter
- Remove redundant NULL pointer validation loops after scoring
- Add comprehensive sanitizer verification to CI including test program
- Enable memory leak detection (detect_leaks=1)
- Add shell-based tests (concurrency and recovery) to sanitizer job
- Add sanitizer log collection and upload for debugging
- Increase timeouts for tests running under sanitizers
- Update warning message to correctly reference tp_insert function
- Remove redundant comment in tp_gettuple
…ucture

Replace the per-backend hash table approach with a simple structure for
tracking query LIMIT values during planning and execution. This change:

- Eliminates hash table overhead (creation, lookup, iteration, cleanup)
- Improves performance through better cache locality
- Simplifies code maintenance and debugging
- Maintains 100% functional correctness for all query patterns
- Preserves proper cleanup between transactions
- Adds safety validation for debugging complex query scenarios

The hash table was overkill for what is essentially a single-value
per-backend cache, since PostgreSQL backends process one query at a time.
The new approach leverages this execution model for better simplicity.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…e leaks

Revert detect_leaks=1 back to detect_leaks=0 to avoid failures caused by
memory leaks in PostgreSQL core utilities (like pg_config) during the
PostgreSQL installation phase.

These leaks are in PostgreSQL's own code, not in Tapir, and were causing
the sanitizer build to fail before it could even test our extension.

The AddressSanitizer will still catch memory corruption issues in our
code while ignoring the harmless PostgreSQL core leaks.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Systematically removed ~177 debug logging statements across all source files:
- posting.c: ~44 debug messages removed
- index.c: ~88 debug messages removed
- memtable.c: ~15 debug messages removed
- stringtable.c: ~8 debug messages removed
- vector.c: ~8 debug messages removed
- limit.c: ~7 debug messages removed
- metapage.c: ~6 debug messages removed
- mod.c: ~1 debug message removed

All elog(DEBUG1, DEBUG2, DEBUG3) calls have been removed while preserving:
- All ERROR, WARNING, NOTICE, and LOG messages (59 total remain)
- All functional code and business logic
- All comments and documentation

The extension builds cleanly and functionality is fully preserved.
Debug logging can be re-added selectively as issues arise in the future.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove useless DSA memory allocation wrapper functions from memtable.c/h
  (tp_dsa_allocate, tp_dsa_free, tp_dsa_get_address provided no added value)
- Remove paranoid pointer validation code from index.c that was checking
  memory address ranges with undefined constants
- Refactor tpvector_score() function by splitting 300-line monolith into
  5 logical helper functions for better maintainability:
  * validate_tpvector_inputs() - Input validation and index name extraction
  * setup_bm25_context() - Index setup and parameter retrieval
  * calculate_doc_length() - Document length calculation
  * find_term_frequency() - Term frequency lookup in document
  * calculate_bm25_term_score() - BM25 score calculation for single term

All tests pass after refactoring. Code is cleaner and more maintainable.
Add comprehensive compiler warning flags to detect unused code:
- Wall, -Wextra, -Wunused-* flags enable detection
- Fix unused parameters by removal or marking as unused
- Fix unused variables by removal
- Fix variable shadowing issues
- Add missing function prototype
- Remove dead code block in tp_rescan
- Fix use-after-free bug in vector error messages
- Suppress multilevel pointer cast warnings in clangd

All regression tests pass with enhanced static analysis enabled.
Add comprehensive compiler warning flags to detect unused code:
- Wall, -Wextra, -Wunused-* flags enable detection
- Fix unused parameters by removal or marking as unused
- Fix unused variables by removal
- Fix variable shadowing issues
- Add missing function prototype
- Remove dead code block in tp_rescan
- Fix use-after-free bug in vector error messages
- Suppress multilevel pointer cast warnings in clangd

All regression tests pass with enhanced static analysis enabled.
@tjgreen42
tjgreen42 merged commit 0d1951c into master Sep 13, 2025
7 checks passed
@tjgreen42
tjgreen42 deleted the tj-initial-implementation branch September 13, 2025 00:50
tjgreen42 pushed a commit that referenced this pull request Jan 6, 2026
Documents at different block positions across terms get partial scores
instead of complete scores. The wand.sql test documents this bug -
doc 201 should be #1 but BMW misses it entirely.

Expected output shows current buggy behavior. Will be updated when
WAND-style doc-ID traversal is implemented.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tjgreen42 added a commit that referenced this pull request May 11, 2026
## Summary

Yesterday's nightly Benchmarks (run 25648758099) hung again on the
*Run MS MARCO insert benchmark* step's bucket-8 query
(`SELECT * FROM benchmark_bucket(8)`, 8+ token BM25). gdb caught one
backend stuck for 32m39s with this stack:

    #0 seek_to_pivot                 (bmw.c:1329)
    #1 score_segment_multi_term_bmw  (bmw.c:1474)
    #2 tp_score_multi_term_bmw       (bmw.c:1602)

`wait_event=NULL`, no locks held but the index's AccessShareLock --
a tight CPU spin inside the `for` loop of `seek_to_pivot`. This is a
*different* call site from #355 (which patched
`block_max_skip_advance`). The fix for #355 added CHECK_FOR_INTERRUPTS
to the WAND outer loop, but `seek_to_pivot` has its own `i--;
continue;` re-entry pattern and the outer CHECK never fires if we
never return to the outer loop.

## Root cause

`seek_to_pivot` walks pivot-region terms and seeks each whose
`cur_doc_id < pivot_doc_id` up to the pivot. After each seek it
calls `restore_ordering` (which may slide a different term into slot
`i`), then `i--; continue;` re-examines slot `i`. Termination relies
on `seek_term_to_doc` strictly advancing `cur_doc_id` past
`pivot_doc_id` on every successful return.

On the production MS MARCO segment topology (8.8M passages, 9-term
query, concurrent-insert segment shapes), `seek_term_to_doc` reports
success but leaves `cur_doc_id < pivot_doc_id`. The `i--; continue;`
then re-enters with the *same* state forever. A deterministic
synthetic repro is elusive -- same as #355, the trigger is
data-driven on the real corpus.

## Fix

1. Defense-in-depth: after a successful `seek_term_to_doc`, check
   whether `cur_doc_id` actually reached `pivot_doc_id`. If not,
   bail out with `false` so the WAND main loop re-pivots. Each
   non-advancing seek still produced *some* state change on at least
   the called term, so outer-loop termination is preserved (or, if
   it isn't, the next iteration's `CHECK_FOR_INTERRUPTS` will catch
   it).

2. Cancelability: add `CHECK_FOR_INTERRUPTS` inside the `for` loop
   so any future regression that reintroduces a non-advancing-seek
   hang is interruptible from SQL (`statement_timeout` /
   `pg_cancel_backend`) instead of needing SIGKILL.

## Verification

- Built clean on PG 18 (-O2 -g, no new warnings).
- All 61 regression tests pass via `pg_regress`.
- `make format-check` clean on src/scoring/bmw.c (pre-existing
  format violations in src/types/query.c and src/debug/dump.c are
  unrelated to this change).

## Open: where does seek_term_to_doc lose advancement?

The bigger question is why `seek_term_to_doc` reports success
without advancing past target on this topology. Possibilities
include stale `block_last_doc_ids` cache vs on-disk postings, or a
boundary case in the Path-A fall-through at bmw.c:881-882 where the
new block's first doc could be loaded into `cur_doc_id` without
explicit `>= target` verification. This is left for a follow-up
investigation once the production hang is unblocked. The fix above
is a safety net, not a closure of the underlying invariant
violation.

Refs: #355 (related but distinct: block_max_skip_advance).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
tjgreen42 added a commit that referenced this pull request May 11, 2026
## Summary

Yesterday's nightly Benchmarks (run 25648758099) hung again on the
*Run MS MARCO insert benchmark* step's bucket-8 query
(`SELECT * FROM benchmark_bucket(8)`, 8+ token BM25). gdb caught one
backend stuck for 32m39s with this stack:

    #0 seek_to_pivot                 (bmw.c:1329)
    #1 score_segment_multi_term_bmw  (bmw.c:1474)
    #2 tp_score_multi_term_bmw       (bmw.c:1602)

`wait_event=NULL`, no locks held but the index's AccessShareLock --
a tight CPU spin inside the `for` loop of `seek_to_pivot`. This is a
*different* call site from #355 (which patched
`block_max_skip_advance`). The fix for #355 added CHECK_FOR_INTERRUPTS
to the WAND outer loop, but `seek_to_pivot` has its own `i--;
continue;` re-entry pattern and the outer CHECK never fires if we
never return to the outer loop.

## Root cause

`seek_to_pivot` walks pivot-region terms and seeks each whose
`cur_doc_id < pivot_doc_id` up to the pivot. After each seek it
calls `restore_ordering` (which may slide a different term into slot
`i`), then `i--; continue;` re-examines slot `i`. Termination relies
on `seek_term_to_doc` strictly advancing `cur_doc_id` past
`pivot_doc_id` on every successful return.

On the production MS MARCO segment topology (8.8M passages, 9-term
query, concurrent-insert segment shapes), `seek_term_to_doc` reports
success but leaves `cur_doc_id < pivot_doc_id`. The `i--; continue;`
then re-enters with the *same* state forever. A deterministic
synthetic repro is elusive -- same as #355, the trigger is
data-driven on the real corpus.

## Fix

1. Defense-in-depth: after a successful `seek_term_to_doc`, check
   whether `cur_doc_id` actually reached `pivot_doc_id`. If not,
   bail out with `false` so the WAND main loop re-pivots. Each
   non-advancing seek still produced *some* state change on at least
   the called term, so outer-loop termination is preserved (or, if
   it isn't, the next iteration's `CHECK_FOR_INTERRUPTS` will catch
   it).

2. Cancelability: add `CHECK_FOR_INTERRUPTS` inside the `for` loop
   so any future regression that reintroduces a non-advancing-seek
   hang is interruptible from SQL (`statement_timeout` /
   `pg_cancel_backend`) instead of needing SIGKILL.

## Verification

- Built clean on PG 18 (-O2 -g, no new warnings).
- All 61 regression tests pass via `pg_regress`.
- `make format-check` clean on src/scoring/bmw.c (pre-existing
  format violations in src/types/query.c and src/debug/dump.c are
  unrelated to this change).

## Open: where does seek_term_to_doc lose advancement?

The bigger question is why `seek_term_to_doc` reports success
without advancing past target on this topology. Possibilities
include stale `block_last_doc_ids` cache vs on-disk postings, or a
boundary case in the Path-A fall-through at bmw.c:881-882 where the
new block's first doc could be loaded into `cur_doc_id` without
explicit `>= target` verification. This is left for a follow-up
investigation once the production hang is unblocked. The fix above
is a safety net, not a closure of the underlying invariant
violation.

Refs: #355 (related but distinct: block_max_skip_advance).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
tjgreen42 added a commit that referenced this pull request May 12, 2026
…cket-8 hang) (#360)

## Summary

Root-cause and fix the MS MARCO bucket-8 hang in production benchmarks
([nightly run
25648758099](https://github.com/timescale/pg_textsearch/actions/runs/25648758099)),
plus close the watchdog gap that allowed the underlying corruption to
persist undetected.

## Root cause: unsorted block_postings in spilled segments

`tp_write_segment` (memtable → segment spill path) iterates a term's
posting-list entries in array order and writes them as
`block_postings[]` *without sorting*. This is fine under single-writer
COPY/CREATE INDEX, because entries are appended in CTID order, which
matches `doc_id` order after `tp_docmap_finalize`. Under **concurrent
inserts**, multiple backends interleave appends to the same posting list
in arbitrary thread-scheduling order:

```c
// src/memtable/posting.c:191
posting_list->is_sorted = false;  /* New entry may break sort order */
```

That `is_sorted` flag is set on every insert but **never set back to
`true`** anywhere — there's no sort step on the read side. The spilled
segment's `block_postings` then violate the sorted-block invariant the
entire BMW machinery relies on (binary search on `block_last_doc_ids`,
`seek_term_to_doc` finding "first doc ≥ target", `find_wand_pivot`'s
smallest-first walk), and merges *propagate* the corruption to higher
levels.

### Diagnostic evidence

A temporary debug function (`bm25_check_segment_consistency`, removed in
the final commit once root cause was fixed) walked every segment / term
/ block and compared cached skip-data against actual on-disk
`block_postings`. Run against the failing benchmark's MS MARCO corpus
before the fix:

```
Checked 2 segments, 4,080,947 terms, 5,776,365 blocks.
Total inconsistencies: 904,174
```

All examples were "not strictly ascending within block", with delta
magnitudes up to ~200,000 doc_ids — genuine out-of-order data, not a
one-byte glitch. (Check #1 — `skip.last_doc_id ≠
postings[doc_count-1].doc_id` — fired zero times, so the skip *metadata*
was always consistent with what got written; what got written was the
bug.)

## Fix

### 1. Sort posting list by doc_id when spilling memtable to segment

`src/segment/segment.c` — `qsort(block_postings, doc_count, ...,
cmp_by_doc_id)` after building it in `tp_write_segment`, before
splitting into `TP_BLOCK_SIZE` blocks. `doc_id` is monotonic with CTID
after `tp_docmap_finalize`, so this is equivalent to sorting by CTID —
the invariant the segment format documents and the merge / scoring code
assumes. `build_context.c` is unaffected (EXPULL streams already-sorted
entries).

### 2. Harden `seek_term_to_doc` to actually scan multiple blocks
(defense-in-depth)

`src/scoring/bmw.c` — The old code had two unverified fall-through paths
that loaded *one* "next" block and returned `true` based purely on
`!iter.finished`, without verifying that the newly loaded block's first
doc actually reached `target_doc_id`. Under correct invariants those
fall-throughs are unreachable, but with the segment-sort bug they fired
and `seek_to_pivot`'s `i--; continue;` re-entry spun forever (the
gdb-observed bucket-8 hang at `bmw.c:1329`).

Restructured so the fast path and binary-search path both just
*position* the iterator at a candidate starting block, then a single
block-advancing scan loop keeps advancing until it finds a posting `>=
target` (returns `true`) or exhausts the iterator. The post-condition
`cur_doc_id >= target_doc_id` is now guaranteed by control flow.

Even with the segment-sort root cause fixed, this is a worthwhile
correctness improvement — the original `seek_term_to_doc` was latently
incorrect for *any* skip-data inconsistency.

### 3. `CHECK_FOR_INTERRUPTS` in BMW hot loops

`src/scoring/bmw.c` — Added in two places:
- Inside the new `for(;;)` block-advancing loop in `seek_term_to_doc`
(per-iter disk I/O + decompression should be cancelable).
- Inside `seek_to_pivot`'s `for` loop (the prior PR #355 added CFI only
to the outer WAND main loop; if `seek_to_pivot` itself spins, we never
return there).

### 4. Validation watchdog actually validates

The corruption above existed for as long as concurrent inserts have, and
we had a validation step (`validate_queries.sql` against
`ground_truth.tsv`) explicitly designed to catch this class of
correctness regression. **It never fired.** Two latent bugs:

- `validate_queries.sql` had an ambiguous-column reference (`WHERE
query_id = p_query_id` where `query_id` shadows a plpgsql variable).
psql errored out partway through with `ON_ERROR_STOP`.
- Every `Validate ...` workflow step pipes `psql` through `tee` (no `set
-o pipefail`), then `grep -q "VALIDATION FAILED"` to decide pass/fail.
When the SQL errors before reaching the FAILED marker, psql's non-zero
exit is swallowed by `tee`, the grep finds nothing, and the step claims
success.

Fixed in `benchmarks/datasets/msmarco/validate_queries.sql` (qualify as
`ground_truth.query_id`) and in `.github/workflows/benchmark.yml` (6
affected `Validate ...` steps): each now sets `set -o pipefail` and
requires an explicit `VALIDATION PASSED` marker — absence of which fails
the step. Future SQL-level errors in validation will fail the run loudly
instead of silently passing. (Cranfield has no validation step in the
workflow at all; out of scope.)

## Verification

- ✅ Built clean on PG 17 / PG 18 (`-O2 -g`, no new warnings)
- ✅ All **61** regression tests pass via `pg_regress`
- ✅ `make format-check` clean
- ✅ Local stress repro: 8-thread pgbench concurrent inserts of 4000 docs
over 8 terms → spill → consistency check (during development). Before
this fix: thousands of inconsistencies. After: 0.
- ✅ **First successful end-to-end benchmark run**
[25689967666](https://github.com/timescale/pg_textsearch/actions/runs/25689967666):
- `Run MS MARCO insert benchmark` completed in seconds (was 32m+ hang) —
bucket-8 p99=59ms, n=100, results=1000
- `Run MS MARCO concurrent insert` (8-thread pgbench, 8.8M passages) ran
the full query suite + validation — first time these steps have ever
reached completion
tjgreen42 added a commit that referenced this pull request Aug 12, 2026
…#441)

## Summary

Fixes the nightly `Benchmarks` workflow, which has failed every night
since
**2026-07-23** — two related failures in the `full-benchmark` and
`insert-benchmark` jobs.

## 1. Wikipedia validation (root cause)

CI installs `wikiextractor` **unpinned**. `wikiextractor 3.0.8` was
released on
**2026-07-23** — the exact day the nightly started failing (previous
release
`3.0.6` was from 2021-10-14). 3.0.8 changes extraction output: with the
*same*
pinned dump (`simplewiki 20260501`) it produces different article
text/ordering,
which shifts `doc_id`s and BM25 scores so results no longer match the
committed
`ground_truth.tsv`. MS MARCO validation is unaffected (it does not use
wikiextractor), confirming corpus drift rather than a scoring
regression.

**Fix:** pin `wikiextractor==3.0.6` — the last version for which the
committed
`ground_truth.tsv` is correct — everywhere it is installed (the four
`benchmark.yml` install steps, both `download.sh` sites, and the
`README.md`
prerequisite). The `download.sh` install is now unconditional (the
previous
`import wikiextractor` guard only checked importability, not version, so
a
persistent env with 3.0.8+ would skip the pin). No ground-truth
regeneration
needed.

## 2. Empty concurrent-benchmark JSON (`[]`)

The `insert-benchmark` job also failed at "Publish … concurrent
benchmark"
(`No benchmark result was found … output was '[]'`). This is a
**cascade**: the
concurrent-insert steps run *after* Wikipedia insert validation and lack
`if: always()`, so when that validation failed they were skipped, no
`CONCURRENT_INSERT_TIME` was logged, and `format_for_action.sh` emitted
an empty
array that `github-action-benchmark` rejects. Fixing #1 stops the
cascade.

**Fix (defense in depth):** `format_for_action.sh` no longer writes an
output
file when the metric array is empty. The publish steps already gate on
`hashFiles('…_action.json') != ''`, so a missing file is skipped cleanly
instead
of hard-failing the job.

## Validation

- Wikipedia: dispatched `benchmark.yml` (`dataset=wikipedia`,
`size=100K`,
`suite=full`, `dry_run=true`) — `VALIDATION PASSED: All 80 queries
match`
  (previously 80/80 failed).
- `[]` guard: unit-tested `format_for_action.sh` (empty input → no file
written;
non-empty → file written) and smoke-tested the insert-benchmark path in
CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants