Skip to content

Scoring cache returns stale relevance after committed updates #14

Description

@erickreutz

Repeating a search after a committed update returns the updated text with a stale relevance score. I reproduced this in plain SQL on the same connection, without Rails or an adapter.

Environment: Lead 1.0.3 at bd95c7e51b6afce81396790852ee2f2c169570ad, PostgreSQL 18.6, Linux arm64.

Reproduction

Run this through psql with its default autocommit behavior, executing each statement separately. Do not wrap the reproduction in BEGIN or send the entire script as one implicit transaction.

CREATE EXTENSION IF NOT EXISTS tin;

CREATE TEMP TABLE lead_cache_probe (
  id integer PRIMARY KEY,
  body text
);
CREATE INDEX ON lead_cache_probe USING tin (body);

INSERT INTO lead_cache_probe VALUES (1, 'Blue sofa');

SELECT body, tin.full_score(ctid) AS score
FROM lead_cache_probe
WHERE body ==> 'sofa';

UPDATE lead_cache_probe SET body = 'Red sofa';

SELECT body, tin.full_score(ctid) AS score
FROM lead_cache_probe
WHERE body ==> 'sofa';

Observed:

Search Returned text Score
Before update Blue sofa 0.2876821
After update Red sofa 0

Both scores should be 0.2876821: the document length and frequency of the searched term are unchanged. Matching and row visibility are correct in this reproduction, but relevance scoring is stale. Sorting by score can therefore return the wrong ranking.

Suspected cause

The score cache key includes GetTopTransactionIdIfAny() and GetCurrentCommandId(false). Separate read-only autocommit transactions can have the same values for both, so the next search reuses a corpus from an earlier snapshot.

The lookup by document text then looks for Red sofa in a cached map containing Blue sofa and defaults to 0.0.

I also reproduced a related case with two connections: a reader stays in a READ COMMITTED transaction with an explicitly assigned transaction ID; another connection commits a text update; the reader repeats the query and sees the new text but receives a zero score. A savepoint around each search does not prevent it. Assigning a transaction ID alone therefore does not address freshness across statement snapshots.

Local workaround

For a local integration proof, I invalidated the corpus at the start of each scoring call:

SCORE_CACHE.with_borrow_mut(|slot| {
    *slot = None;
    // Existing cache construction and scoring follow.

The same SQL then returns 0.2876821 before and after the update. Regression tests for both successive autocommit searches and an external commit during a READ COMMITTED transaction pass with this change.

This intentionally trades performance for correctness and is not a proposal to discard caching permanently. The cache needs a lifetime or invalidation rule that respects the statement's visible snapshot. I have not tested production TIN for this behavior.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions