Combine PostgreSQL Visibility schema upgrades from v1.10 through v1.13 (#10371)

## What changed?
Combine PostgreSQL Visibility schema upgrades from v1.10 through v1.13

## Why?
Adding one column at a time requires scanning the entire table. So,
combining all the schema changes in v1.30.0 release into a single SQL
upgrade file.

https://github.com/temporalio/temporal/issues/10358

## How did you test it?
- [x] built
- [x] run locally and tested manually
- [x] covered by existing tests
- [ ] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks
This commit is contained in:
Rodrigo Zhou
2026-06-04 15:36:09 -07:00
committed by GitHub
parent fc07e0b28a
commit afdffd76a4
7 changed files with 237 additions and 25 deletions

View File

@@ -14,14 +14,22 @@ const (
queryDelimiter = ';'
querySliceDefaultSize = 100
sqlLeftParenthesis = '('
sqlRightParenthesis = ')'
sqlIfKeyword = "if"
sqlBeginKeyword = "begin"
sqlEndKeyword = "end"
sqlLineComment = "--"
sqlSingleQuote = '\''
sqlDoubleQuote = '"'
sqlLeftParenthesis = '('
sqlRightParenthesis = ')'
sqlCreateKeyword = "create"
sqlTableKeyword = "table"
sqlIndexKeyword = "index"
sqlConcurrentlyKeyword = "concurrently"
sqlAddKeyword = "add"
sqlColumnKeyword = "column"
sqlDoubleDollarKeyword = "$$"
sqlIfKeyword = "if"
sqlLoopKeyword = "loop"
sqlBeginKeyword = "begin"
sqlEndKeyword = "end"
sqlLineComment = "--"
sqlSingleQuote = '\''
sqlDoubleQuote = '"'
)
// LoadAndSplitQuery loads and split cql / sql query into one statement per string.
@@ -78,11 +86,37 @@ func LoadAndSplitQueryFromReaders(
}
st = st[:len(st)-1]
case sqlIfKeyword[0]:
if hasWordAt(contentStr, sqlIfKeyword, j) {
st = append(st, sqlIfKeyword[0])
j += len(sqlIfKeyword) - 1
case sqlDoubleDollarKeyword[0]:
if !hasWordAt(contentStr, sqlDoubleDollarKeyword, j) {
continue
}
if len(st) == 0 || st[len(st)-1] != sqlDoubleDollarKeyword[0] {
st = append(st, sqlDoubleDollarKeyword[0])
j += len(sqlDoubleDollarKeyword) - 1
} else {
st = st[:len(st)-1]
j += len(sqlDoubleDollarKeyword) - 1
}
case sqlIfKeyword[0]:
if !hasWordAt(contentStr, sqlIfKeyword, j) {
continue
}
if hasWordsBefore(contentStr, j-1, sqlAddKeyword, sqlColumnKeyword) ||
hasWordsBefore(contentStr, j-1, sqlCreateKeyword, sqlIndexKeyword) ||
hasWordsBefore(contentStr, j-1, sqlCreateKeyword, sqlIndexKeyword, sqlConcurrentlyKeyword) ||
hasWordsBefore(contentStr, j-1, sqlCreateKeyword, sqlTableKeyword) {
continue
}
st = append(st, sqlIfKeyword[0])
j += len(sqlIfKeyword) - 1
case sqlLoopKeyword[0]:
if !hasWordAt(contentStr, sqlLoopKeyword, j) {
continue
}
st = append(st, sqlLoopKeyword[0])
j += len(sqlLoopKeyword) - 1
case sqlBeginKeyword[0]:
if hasWordAt(contentStr, sqlBeginKeyword, j) {
@@ -100,6 +134,13 @@ func LoadAndSplitQueryFromReaders(
}
st = st[:len(st)-1]
j = after + len(sqlIfKeyword) - 1
} else if ok, after := hasWordAfter(contentStr, sqlLoopKeyword, j+len(sqlEndKeyword)); ok {
//nolint:revive
if len(st) == 0 || st[len(st)-1] != sqlLoopKeyword[0] {
return nil, errors.New("error reading contents: unmatched `END LOOP` keyword")
}
st = st[:len(st)-1]
j = after + len(sqlLoopKeyword) - 1
} else {
if len(st) == 0 || st[len(st)-1] != sqlBeginKeyword[0] {
return nil, errors.New("error reading contents: unmatched `END` keyword")
@@ -183,6 +224,35 @@ func hasWordAfter(s, word string, pos int) (bool, int) {
return hasWordAt(s, word, after), after
}
// hasWordsBefore checks if the given words appears before position pos in s,
// separated by at least one space, and each word is a whole word.
// Words must appear is the given order.
// Eg: hasWordsBefore("CREATE TABLE IF", 12, "CREATE", "TABLE") returns true.
func hasWordsBefore(s string, pos int, words ...string) bool {
if len(words) == 0 {
return true
}
if pos <= 0 || !unicode.IsSpace(rune(s[pos])) {
return false
}
for i := len(words) - 1; i >= 0; i-- {
// skip spaces
for pos >= 0 && unicode.IsSpace(rune(s[pos])) {
pos--
}
// go to first char of word
for pos > 0 && isAlphanumeric(s[pos-1]) {
pos--
}
// check if current pos matches the word
if pos < 0 || !hasWordAt(s, words[i], pos) {
return false
}
pos--
}
return true
}
func isAlphanumeric(c byte) bool {
return unicode.IsLetter(rune(c)) || unicode.IsDigit(rune(c))
}

View File

@@ -128,3 +128,54 @@ func (s *queryUtilSuite) TestHasWordAt() {
s.False(hasWordAt("7BEGIN", "BEGIN", 1))
s.False(hasWordAt("BEGIN7", "BEGIN", 0))
}
func (s *queryUtilSuite) TestHasWordsBefore() {
// example from the function's doc comment
s.True(hasWordsBefore("CREATE TABLE IF", 12, "CREATE", "TABLE"))
// empty words slice always returns true, regardless of pos
s.True(hasWordsBefore("CREATE TABLE", 6))
s.True(hasWordsBefore("", 0))
s.True(hasWordsBefore("anything", 3))
// pos <= 0 returns false when words are provided
s.False(hasWordsBefore("CREATE TABLE", 0, "CREATE"))
s.False(hasWordsBefore(" CREATE", 0, "CREATE"))
s.False(hasWordsBefore(" CREATE", 2, "CREATE"))
// pos must point to a whitespace character
s.False(hasWordsBefore("CREATE TABLE", 5, "CREATE")) // s[5]='E'
s.False(hasWordsBefore("CREATE TABLE", 4, "CREATE")) // s[4]='T'
// single word matches
s.True(hasWordsBefore("CREATE TABLE", 6, "CREATE"))
s.True(hasWordsBefore(" CREATE ", 8, "CREATE")) // leading whitespace ok
s.True(hasWordsBefore("(CREATE ", 7, "CREATE")) // preceded by non-alphanumeric
s.True(hasWordsBefore("CREATE\tIF", 6, "CREATE")) // tab counts as whitespace
s.True(hasWordsBefore("CREATE ", 8, "CREATE")) // multiple trailing spaces ok
// single word - wrong word at that position
s.False(hasWordsBefore("CREATE TABLE", 6, "TABLE"))
// word must be a whole word (alphanumeric adjacency rejected)
s.False(hasWordsBefore("XCREATE ", 7, "CREATE"))
s.False(hasWordsBefore("7CREATE ", 7, "CREATE"))
// multiple words match in order
s.True(hasWordsBefore("ADD COLUMN IF", 10, "ADD", "COLUMN"))
s.True(hasWordsBefore("CREATE INDEX IF", 12, "CREATE", "INDEX"))
// multiple words with extra whitespace between them
s.True(hasWordsBefore("CREATE TABLE IF", 14, "CREATE", "TABLE"))
s.True(hasWordsBefore("CREATE\tTABLE\tIF", 12, "CREATE", "TABLE"))
// words in wrong order
s.False(hasWordsBefore("CREATE TABLE IF", 12, "TABLE", "CREATE"))
// one of the words is missing / different
s.False(hasWordsBefore("CREATE FOO IF", 10, "CREATE", "TABLE"))
s.False(hasWordsBefore("DROP TABLE IF", 10, "CREATE", "TABLE"))
// more words requested than are available before pos
s.False(hasWordsBefore("TABLE ", 5, "CREATE", "TABLE"))
}

View File

@@ -2,7 +2,6 @@
"CurrVersion": "1.10",
"MinCompatibleVersion": "0.1",
"Description": "add TemporalReportedProblems column",
"SchemaUpdateCqlFiles": [
"add_temporal_reported_columns.sql"
]
}
"SchemaUpdateCqlFiles": [],
"AllowNoCqlFiles": true
}

View File

@@ -2,7 +2,6 @@
"CurrVersion": "1.11",
"MinCompatibleVersion": "0.1",
"Description": "add CHASM search attributes columns and indices",
"SchemaUpdateCqlFiles": [
"add_chasm_search_attributes.sql"
]
"SchemaUpdateCqlFiles": [],
"AllowNoCqlFiles": true
}

View File

@@ -2,7 +2,6 @@
"CurrVersion": "1.12",
"MinCompatibleVersion": "0.1",
"Description": "add TemporalLowCardinalityKeyword01 to chasm_search_attributes",
"SchemaUpdateCqlFiles": [
"add_low_cardinality_keyword.sql"
]
"SchemaUpdateCqlFiles": [],
"AllowNoCqlFiles": true
}

View File

@@ -0,0 +1,94 @@
-- Add new columns
ALTER TABLE executions_visibility
-- v1.10
ADD COLUMN IF NOT EXISTS TemporalReportedProblems JSONB GENERATED ALWAYS AS (search_attributes->'TemporalReportedProblems') STORED,
-- v1.11
-- Pre-allocated CHASM search attributes
ADD COLUMN IF NOT EXISTS TemporalBool01 BOOLEAN GENERATED ALWAYS AS ((search_attributes->'TemporalBool01')::boolean) STORED,
ADD COLUMN IF NOT EXISTS TemporalBool02 BOOLEAN GENERATED ALWAYS AS ((search_attributes->'TemporalBool02')::boolean) STORED,
ADD COLUMN IF NOT EXISTS TemporalDatetime01 TIMESTAMP GENERATED ALWAYS AS (convert_ts(search_attributes->>'TemporalDatetime01')) STORED,
ADD COLUMN IF NOT EXISTS TemporalDatetime02 TIMESTAMP GENERATED ALWAYS AS (convert_ts(search_attributes->>'TemporalDatetime02')) STORED,
ADD COLUMN IF NOT EXISTS TemporalDouble01 DECIMAL(20, 5) GENERATED ALWAYS AS ((search_attributes->'TemporalDouble01')::decimal) STORED,
ADD COLUMN IF NOT EXISTS TemporalDouble02 DECIMAL(20, 5) GENERATED ALWAYS AS ((search_attributes->'TemporalDouble02')::decimal) STORED,
ADD COLUMN IF NOT EXISTS TemporalInt01 BIGINT GENERATED ALWAYS AS ((search_attributes->'TemporalInt01')::bigint) STORED,
ADD COLUMN IF NOT EXISTS TemporalInt02 BIGINT GENERATED ALWAYS AS ((search_attributes->'TemporalInt02')::bigint) STORED,
ADD COLUMN IF NOT EXISTS TemporalKeyword01 VARCHAR(255) GENERATED ALWAYS AS (search_attributes->>'TemporalKeyword01') STORED,
ADD COLUMN IF NOT EXISTS TemporalKeyword02 VARCHAR(255) GENERATED ALWAYS AS (search_attributes->>'TemporalKeyword02') STORED,
ADD COLUMN IF NOT EXISTS TemporalKeyword03 VARCHAR(255) GENERATED ALWAYS AS (search_attributes->>'TemporalKeyword03') STORED,
ADD COLUMN IF NOT EXISTS TemporalKeyword04 VARCHAR(255) GENERATED ALWAYS AS (search_attributes->>'TemporalKeyword04') STORED,
ADD COLUMN IF NOT EXISTS TemporalKeywordList01 JSONB GENERATED ALWAYS AS (search_attributes->'TemporalKeywordList01') STORED,
ADD COLUMN IF NOT EXISTS TemporalKeywordList02 JSONB GENERATED ALWAYS AS (search_attributes->'TemporalKeywordList02') STORED,
-- v1.12
ADD COLUMN IF NOT EXISTS TemporalLowCardinalityKeyword01 VARCHAR(255) GENERATED ALWAYS AS (search_attributes->>'TemporalLowCardinalityKeyword01') STORED,
-- v1.13
ADD COLUMN IF NOT EXISTS TemporalUsedWorkerDeploymentVersions JSONB GENERATED ALWAYS AS (search_attributes->'TemporalUsedWorkerDeploymentVersions') STORED;
-- Drop invalid indices
DO LANGUAGE 'plpgsql' $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT i.relname as indexname
FROM
pg_class i,
pg_index ix
WHERE
i.oid = ix.indexrelid
AND ix.indrelid = (SELECT oid FROM pg_class WHERE relname = 'executions_visibility')
AND i.relname IN (
'by_temporal_reported_problems',
'by_temporal_bool_01',
'by_temporal_bool_02',
'by_temporal_datetime_01',
'by_temporal_datetime_02',
'by_temporal_double_01',
'by_temporal_double_02',
'by_temporal_int_01',
'by_temporal_int_02',
'by_temporal_keyword_01',
'by_temporal_keyword_02',
'by_temporal_keyword_03',
'by_temporal_keyword_04',
'by_temporal_keyword_list_01',
'by_temporal_keyword_list_02',
'by_temporal_low_cardinality_keyword_01',
'by_used_deployment_versions'
)
AND NOT ix.indisvalid
LOOP
EXECUTE format('DROP INDEX %I', r.indexname);
RAISE NOTICE 'Dropped invalid index %', r.indexname;
END LOOP;
END $$;
-- Create new indices
-- v1.10
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_reported_problems ON executions_visibility USING GIN (namespace_id, TemporalReportedProblems jsonb_path_ops);
-- v1.11
-- Indexes for the pre-allocated CHASM search attributes
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_bool_01 ON executions_visibility (namespace_id, TemporalBool01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_bool_02 ON executions_visibility (namespace_id, TemporalBool02, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_datetime_01 ON executions_visibility (namespace_id, TemporalDatetime01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_datetime_02 ON executions_visibility (namespace_id, TemporalDatetime02, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_double_01 ON executions_visibility (namespace_id, TemporalDouble01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_double_02 ON executions_visibility (namespace_id, TemporalDouble02, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_int_01 ON executions_visibility (namespace_id, TemporalInt01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_int_02 ON executions_visibility (namespace_id, TemporalInt02, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_01 ON executions_visibility (namespace_id, TemporalKeyword01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_02 ON executions_visibility (namespace_id, TemporalKeyword02, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_03 ON executions_visibility (namespace_id, TemporalKeyword03, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_04 ON executions_visibility (namespace_id, TemporalKeyword04, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_list_01 ON executions_visibility USING GIN (namespace_id, TemporalKeywordList01 jsonb_path_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_keyword_list_02 ON executions_visibility USING GIN (namespace_id, TemporalKeywordList02 jsonb_path_ops);
-- v1.12
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_temporal_low_cardinality_keyword_01 ON executions_visibility (namespace_id, TemporalLowCardinalityKeyword01, (COALESCE(close_time, '9999-12-31 23:59:59')) DESC, start_time DESC, run_id);
-- v1.13
CREATE INDEX CONCURRENTLY IF NOT EXISTS by_used_deployment_versions ON executions_visibility USING GIN (namespace_id, TemporalUsedWorkerDeploymentVersions jsonb_path_ops);

View File

@@ -1,8 +1,8 @@
{
"CurrVersion": "1.13",
"MinCompatibleVersion": "0.1",
"Description": "add TemporalUsedWorkerDeploymentVersions search attribute",
"Description": "combined changed from v1.10 to v1.13 (add TemporalUsedWorkerDeploymentVersions search attribute)",
"SchemaUpdateCqlFiles": [
"add_used_deployment_versions_search_attribute.sql"
"combined_v1.10_v1.13.sql"
]
}
}