Fix PostgreSQL visibility failure on Text search attributes containing ':' - #11085
Open
garfieldnate wants to merge 1 commit into
Open
Fix PostgreSQL visibility failure on Text search attributes containing ':'#11085garfieldnate wants to merge 1 commit into
garfieldnate wants to merge 1 commit into
Conversation
…g ':'
The PostgreSQL advanced visibility Text columns (Text01/02/03) are
generated columns defined with a raw ::tsvector cast:
(search_attributes->>'Text01')::tsvector
The cast requires valid tsvector literal syntax, so any value containing
a character significant to that syntax (most commonly ':') raises
"syntax error in tsvector" and fails the whole executions_visibility
upsert. In practice RecordWorkflowExecutionClosed fails, the visibility
task retries and lands in the DLQ, and the completed workflow shows as
Running in the UI until retention deletes the record. MySQL and SQLite
store Text as plain text and are unaffected.
The read path has the same problem: the query converter cast the query
value with a raw ::tsquery, which errors on tokens with an internal ':'
(e.g. "World:Jurassic").
Convert both sides with the text-search functions instead of raw casts,
using the 'simple' configuration on both so they stay consistent:
- schema: to_tsvector('simple', search_attributes->>'TextNN'), which
never fails on special characters.
- query converter: OR-combine per-token plainto_tsquery('simple', ...)
calls, preserving the previous OR-of-tokens semantics while letting
plainto_tsquery normalize significant characters.
'simple' keeps token-based matching (no stemming/stopwords) and makes
Text matching case-insensitive, matching the MySQL and SQLite stores and
the new to_tsvector config so queries still match stored values.
Add a versioned upgrade script (v1.15) that recomputes the columns and
their GIN indexes, and bump the embedded visibility schema version. The
migration is safe: to_tsvector never errors and rows with bad values
were never stored under the old cast.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
I work at Nonsense, and I run a lot of workflows on movies. We use internal IDs for most things, but for search attributes we like to use human-readable text such as movie titles. Since introducing these attributes, our Temporal UI has become very difficult to use because hundreds of workflows that show as "Running" are actually completed, which we don't know until we click on them.
The diagnosis and fix below are AI-/Fable-generated (but still edited/vetted by me).
Problem
On PostgreSQL advanced visibility, any
Textsearch attribute value containing a character significant to tsvector literal syntax — most commonly:, but also an unbalanced'— fails the entireexecutions_visibilityrow INSERT/UPDATE:The cause is the generated column definition in
schema/postgresql/v12/visibility/schema.sql, which uses a raw cast:A raw
::tsvectorcast doesn't tokenize free text — it parses the value as a tsvector literal, in which:introduces lexeme positions (valid literal syntax looks like'jurassic':4 'park':5). ParsingThe Lost World: Jurassic Park, Postgres reads the tokenWorld:and expects position digits after the colon; the space that follows makes the literal invalid and the cast throws. Without the colon it happens to work —'The Lost World Jurassic Park'::tsvectorparses fine as five lexemes — which is why the bug stays hidden until real-world punctuated text shows up. The cast runs while Postgres computes the generated column, so the whole row write is rejected.Smallest demonstration (this is exactly what the generated column executes):
Consequences in production
RecordWorkflowExecutionClosedfails (service/history/visibility_queue_task_executor.go→processCloseExecution); the visibility task retries and is eventually sent to the DLQ ("Marking task as terminally failed, will send to DLQ").UpsertWorkflowSearchAttributesvisibility tasks fail the same way.Textattribute) ~0.7% of workflows hit this, producing a permanent, self-replenishing pool of ~800 phantom "Running" workflows and a steady stream of DLQ'd visibility tasks.This has been reported by the community with no fix to date:
https://community.temporal.io/t/temporal-workflow-visibility-error-pq-syntax-error-in-tsvector-mocked-traceid-5c060ad0-5ec8-4ebb-b344-5558a9f11111/10625
Verified on v1.28.1; the schema and converters are unchanged on
mainand v1.30.1. MySQL (TEXT+ FULLTEXT) and SQLite (TEXT+ FTS5) store the value as plain text and are unaffected — only PostgreSQL uses the raw cast.The read side has the sibling bug
The query converters (
common/persistence/sql/sqlplugin/postgresql/query_converter.goand the legacycommon/persistence/visibility/store/sql/query_converter_legacy_postgresql.go) tokenize the query value on spaces and emit a raw::tsquerycast. Filtering on the very title that was stored —WHERE Title = 'The Lost World: Jurassic Park'— becomesText01 @@ 'The | Lost | World: | Jurassic | Park'::tsquery, and the tokenWorld:throwssyntax error in tsquery. Any colon-bearing token (a URL,key:valuetags) does the same, soListWorkflowExecutions/CountWorkflowExecutionsfiltering on such a Text value errors as well.Fix
Convert both sides with the PostgreSQL text-search functions instead of raw casts, using the
simpletext search configuration on both sides so they stay consistent:to_tsvector('simple', search_attributes->>'TextNN').to_tsvectortokenizes arbitrary text and never fails on special characters.plainto_tsquery('simple', <token>)and the tokens are OR-combined with thetsquery
||operator, e.g.Text01 @@ (plainto_tsquery('simple', 'The') || plainto_tsquery('simple', 'Lost') || ...).plainto_tsquerynormalizes the token, so significant characters no longercause a parse error, and the OR-of-tokens semantics of the previous
implementation is preserved.
simplefolds case and splits on non-word characters without stemming or stopword removal. This keeps the token-based matching semantics of the old implementation, aligns case handling with the MySQL and SQLite stores (both are case-insensitive), and — importantly — matches theto_tsvectorconfig so theplainto_tsquerylexemes still match the storedtsvectorlexemes. (A language config like'english'was rejected: Temporal has no per-namespace language knowledge, and stemming/stopword removal would make some exact-token queries silently match nothing.)The read side is fixed in both query-converter implementations that exist on
main:common/persistence/sql/sqlplugin/postgresql/query_converter.gocommon/persistence/visibility/store/sql/query_converter_legacy_postgresql.goAlternatives considered
search_attributesJSONB (which the API/UI return, so users would see mangled values), the escaping rules are fiddly, and the read-side::tsquerybug would need separate escaping.Migration
schema/postgresql/v12/visibility/schema.sqlis updated for newinstallations.
schema/postgresql/v12/visibility/versioned/v1.15/fix_text_search_attributes_tsvector.sql,drops and re-adds the three generated columns with the new expression (which
also drops and recreates the dependent
by_text_0NGIN indexes).(
schema/postgresql/v12.VisibilityVersion) is bumped1.14->1.15.The migration recomputes the stored
tsvectorfor every existing row. This issafe:
to_tsvectornever errors, and any row already in the table must have hada value the old raw cast accepted (rows with bad values were rejected at write
time and never stored), so nothing that previously stored can fail to recompute.
Compatibility considerations
representation: the old raw cast preserved case (
'World'), the newto_tsvector('simple', ...)lowercases ('world'). This is why the read sidemust change in lockstep — a raw
::tsquery(case-preserving) would stopmatching the lowercased stored values.
Textmatching becomes case-insensitive onPostgreSQL, matching MySQL and SQLite. OR-of-tokens matching is unchanged
(maintainers may prefer AND-of-tokens; happy to change, but OR is what the
current code does).
large visibility tables this deserves a maintenance window or a concurrent
strategy.
Tests / evidence
Run against a real PostgreSQL 13.5 (the repo's
make start-dependenciescontainer), Go 1.26,
-tags test_dep.New end-to-end persistence test (write path)
common/persistence/sql/sqlplugin/testsvisibility suite:TestInsertReplaceSelect_TextSearchAttributesWithSpecialCharsrecords a closedworkflow (
InsertIntoVisibility+ReplaceIntoVisibility, theRecordWorkflowExecutionClosedpath) withText01 = "The Lost World: Jurassic Park"andText02 = "it's a wonderful life", then reads the row back.Before the schema fix (raw
::tsvectorcast restored), against real Postgres:After the fix:
New query-converter unit tests (read path)
value with colon tokencases added to bothcommon/persistence/sql/sqlplugin/postgresqlandcommon/persistence/visibility/store/sqlconverter tests: a valueThe Lost World:Jurassic Parknow renders as OR-combinedplainto_tsquerycalls instead of an invalid
::tsquerycast. Both pass.Migration test
tools/testsTestPostgres*/TestPostgreSQLUpdateSchemaTestSuiteapplies thefull versioned migration chain and reaches
1.15; passes for both thepqandpgxplugins.Regression
The full
TestPQ/TestPostgreSQLVisibilitySuite, theschemaembed test, andthe
common/persistence/visibility/store/querytests all pass.