Skip to content

Commit fd81933

Browse files
Add a self-contained v8 search schema revision (PP-4659) (#3522)
## Description Adds a self-contained v8 OpenSearch schema revision (`SearchV8`) and registers it in the revision directory. Unlike earlier revisions, which chained off one another (`SearchV7` -> `SearchV6` -> `SearchV5`), v8 subclasses `SearchSchemaRevision` directly and defines its complete mapping (analyzers, filters, and fields) inline. Breaking the chain means each revision stands alone, so an old revision's module can simply be deleted once nothing in production is using it. Schema-wise v8 is equivalent to v7 (the full v5 mapping plus v6's `lane_priority_level` and v7's `licensepools.last_updated`). The mapping is unchanged; the differences are all in the index settings, which v8 now pins explicitly so a newly created index is fully deterministic rather than relying on inherited cluster defaults: - **`number_of_shards = 1`** — earlier indexes inherited a 5-primary-shard count from the original Elasticsearch 6.x defaults, carried forward through every reindex because nothing set it explicitly. Per-library indexes are well under a gigabyte, far below the per-shard target for search workloads, so a single primary shard is correct. This setting is immutable after index creation, so it must be set up front. - **`number_of_replicas = 1`** — matches what every production index already runs and is also the OpenSearch default, so this is operationally a no-op. It is a dynamic setting and can still be retuned at runtime for a larger topology; it is pinned only so indexes are created with a known replica count. - **`index.search.slowlog.threshold.*`** — seeds slow-query-log thresholds on every index this revision creates, so slow query- and fetch-phases are written to the cluster slow log. Paired with the `SEARCH_SLOW_LOGS` publishing wired up in the hosting playbook, these entries reach the domain's CloudWatch log group. These are dynamic settings, so they establish a baseline at index creation that can still be retuned on a live index without a reindex. ## Motivation and Context The revision chain meant no old schema version could be removed without breaking the revisions built on top of it, so deprecated versions accumulated indefinitely. Making each revision self-contained lets old versions be deleted once production no longer uses them. Pinning the index settings replaces inherited, undocumented cluster defaults with an explicit, deterministic configuration — most importantly the shard count, which is immutable after index creation and had been silently carried over from the Elasticsearch era. JIRA: PP-4659 ## How Has This Been Tested? Added `tests/manager/search/test_search_v8.py`, covering the version number, registration in the revision directory, the pinned shard/replica counts, and the seeded slow-query-log thresholds in the generated mapping document. Ran the search test suite under the docker tox environment. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed.
1 parent 897a244 commit fd81933

3 files changed

Lines changed: 337 additions & 1 deletion

File tree

src/palace/manager/search/revision_directory.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
from palace.manager.search.v5 import SearchV5
66
from palace.manager.search.v6 import SearchV6
77
from palace.manager.search.v7 import SearchV7
8+
from palace.manager.search.v8 import SearchV8
89

9-
REVISIONS = [SearchV5(), SearchV6(), SearchV7()]
10+
REVISIONS = [SearchV5(), SearchV6(), SearchV7(), SearchV8()]
1011

1112

1213
class SearchRevisionDirectory:

src/palace/manager/search/v8.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
from palace.manager.search.document import (
2+
BASIC_TEXT,
3+
BOOLEAN,
4+
FILTERABLE_TEXT,
5+
FLOAT,
6+
INTEGER,
7+
LONG,
8+
SearchMappingDocument,
9+
SearchMappingFieldType,
10+
icu_collation_keyword,
11+
keyword,
12+
nested,
13+
sort_author_keyword,
14+
)
15+
from palace.manager.search.revision import SearchSchemaRevision
16+
17+
18+
class SearchV8(SearchSchemaRevision):
19+
"""The version 8 search schema revision.
20+
21+
This revision is intentionally **self-contained**: it does not inherit from
22+
any previous revision and defines its complete mapping (analyzers, filters,
23+
and fields) directly. This is how new schema revisions are meant to be defined.
24+
25+
We formed a bad habit for several old schema revisions where they just chained
26+
off one another (``SearchV7`` -> ``SearchV6`` -> ``SearchV5``) which meant
27+
no old revision could be removed without breaking the ones build on top of
28+
it.
29+
30+
From now on every revision should stand alone so that, once nothing in
31+
production is using an older version, that version's module can simply be deleted.
32+
33+
Changes in v8:
34+
- The shard and replica counts are set explicitly.
35+
- sets search slow-query-log thresholds
36+
"""
37+
38+
@property
39+
def version(self) -> int:
40+
return 8
41+
42+
# The number of primary shards for indexes created by this revision.
43+
# This setting is immutable once an index is created.
44+
NUMBER_OF_SHARDS = 1
45+
46+
# The number of replicas for indexes created by this revision.
47+
NUMBER_OF_REPLICAS = 1
48+
49+
# Slow-query-log thresholds applied to every index this revision creates.
50+
# Keys are relative to "index".
51+
SEARCH_SLOWLOG_THRESHOLDS = {
52+
"search.slowlog.threshold.query.warn": "3s",
53+
"search.slowlog.threshold.query.info": "1s",
54+
"search.slowlog.threshold.fetch.warn": "3s",
55+
"search.slowlog.threshold.fetch.info": "1s",
56+
}
57+
58+
# Use regular expressions to normalized values in sortable fields.
59+
# These regexes are applied in order; that way "H. G. Wells"
60+
# becomes "H G Wells" becomes "HG Wells".
61+
CHAR_FILTERS = {
62+
"remove_apostrophes": dict(
63+
type="pattern_replace",
64+
pattern="'",
65+
replacement="",
66+
)
67+
}
68+
69+
AUTHOR_CHAR_FILTER_NAMES = []
70+
for name, pattern, replacement in [
71+
# The special author name "[Unknown]" should sort after everything
72+
# else. REPLACEMENT CHARACTER is the final valid Unicode character.
73+
("unknown_author", r"\[Unknown\]", "\N{REPLACEMENT CHARACTER}"),
74+
# Works by a given primary author should be secondarily sorted
75+
# by title, not by the other contributors.
76+
("primary_author_only", r"\s+;.*", ""),
77+
# Remove parentheticals (e.g. the full name of someone who
78+
# goes by initials).
79+
("strip_parentheticals", r"\s+\([^)]+\)", ""),
80+
# Remove periods from consideration.
81+
("strip_periods", r"\.", ""),
82+
# Collapse spaces for people whose sort names end with initials.
83+
("collapse_three_initials", r" ([A-Z]) ([A-Z]) ([A-Z])$", " $1$2$3"),
84+
("collapse_two_initials", r" ([A-Z]) ([A-Z])$", " $1$2"),
85+
]:
86+
normalizer = dict(
87+
type="pattern_replace", pattern=pattern, replacement=replacement
88+
)
89+
CHAR_FILTERS[name] = normalizer
90+
AUTHOR_CHAR_FILTER_NAMES.append(name)
91+
92+
def __init__(self) -> None:
93+
super().__init__()
94+
95+
self._normalizers = {}
96+
self._filters = {}
97+
self._analyzers = {}
98+
99+
# Set up character filters.
100+
#
101+
self._char_filters = self.CHAR_FILTERS
102+
103+
# This normalizer is used on freeform strings that
104+
# will be used as tokens in filters. This way we can,
105+
# e.g. ignore capitalization when considering whether
106+
# two books belong to the same series or whether two
107+
# author names are the same.
108+
self._normalizers["filterable_string"] = dict(
109+
type="custom", filter=["lowercase", "asciifolding"]
110+
)
111+
112+
# Set up analyzers.
113+
#
114+
115+
# We use three analyzers:
116+
#
117+
# 1. An analyzer based on Opensearch's default English
118+
# analyzer, with a normal stemmer -- used as the default
119+
# view of a text field such as 'description'.
120+
#
121+
# 2. An analyzer that's exactly the same as #1 but with a less
122+
# aggressive stemmer -- used as the 'minimal' view of a
123+
# text field such as 'description.minimal'.
124+
#
125+
# 3. An analyzer that's exactly the same as #2 but with
126+
# English stopwords left in place instead of filtered out --
127+
# used as the 'with_stopwords' view of a text field such as
128+
# 'title.with_stopwords'.
129+
#
130+
# The analyzers are identical except for the end of the filter
131+
# chain.
132+
#
133+
# All three analyzers are based on Opensearch's default English
134+
# analyzer, defined here:
135+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html#english-analyzer
136+
137+
# First, recreate the filters from the default English
138+
# analyzer. We'll be using these to build our own analyzers.
139+
140+
# Filter out English stopwords.
141+
self._filters["english_stop"] = dict(type="stop", stopwords=["_english_"])
142+
# The default English stemmer, used in the en_default analyzer.
143+
self._filters["english_stemmer"] = dict(type="stemmer", language="english")
144+
# A less aggressive English stemmer, used in the en_minimal analyzer.
145+
self._filters["minimal_english_stemmer"] = dict(
146+
type="stemmer", language="minimal_english"
147+
)
148+
# A filter that removes English posessives such as "'s"
149+
self._filters["english_posessive_stemmer"] = dict(
150+
type="stemmer", language="possessive_english"
151+
)
152+
153+
# Some potentially useful filters that are currently not used:
154+
#
155+
# * keyword_marker -- Exempt certain keywords from stemming
156+
# * synonym -- Introduce synonyms for words
157+
# (but probably better to use synonym_graph during the search
158+
# -- it's more flexible).
159+
160+
# Here's the common analyzer configuration. The comment NEW
161+
# means this is something we added on top of Opensearch's
162+
# default configuration for the English analyzer.
163+
common_text_analyzer = dict(
164+
type="custom",
165+
char_filter=["html_strip", "remove_apostrophes"], # NEW
166+
tokenizer="standard",
167+
)
168+
common_filter = [
169+
"lowercase",
170+
"asciifolding", # NEW
171+
]
172+
173+
# The default_text_analyzer uses Opensearch's standard
174+
# English stemmer and removes stopwords.
175+
self._analyzers["en_default_text_analyzer"] = dict(common_text_analyzer)
176+
self._analyzers["en_default_text_analyzer"]["filter"] = common_filter + [
177+
"english_stop",
178+
"english_stemmer",
179+
]
180+
181+
# The minimal_text_analyzer uses a less aggressive English
182+
# stemmer, and removes stopwords.
183+
self._analyzers["en_minimal_text_analyzer"] = dict(common_text_analyzer)
184+
self._analyzers["en_minimal_text_analyzer"]["filter"] = common_filter + [
185+
"english_stop",
186+
"minimal_english_stemmer",
187+
]
188+
189+
# The en_with_stopwords_text_analyzer uses the less aggressive
190+
# stemmer and does not remove stopwords.
191+
self._analyzers["en_with_stopwords_text_analyzer"] = dict(common_text_analyzer)
192+
self._analyzers["en_with_stopwords_text_analyzer"]["filter"] = common_filter + [
193+
"minimal_english_stemmer"
194+
]
195+
196+
# Now we need to define a special analyzer used only by the
197+
# 'sort_author' property.
198+
199+
# Here's a special filter used only by that analyzer. It
200+
# duplicates the filter used by the icu_collation_keyword data
201+
# type.
202+
self._filters["en_sortable_filter"] = dict(
203+
type="icu_collation", language="en", country="US"
204+
)
205+
206+
# Here's the analyzer used by the 'sort_author' property.
207+
# It's the same as icu_collation_keyword, but it has some
208+
# extra character filters -- regexes that do things like
209+
# convert "Tolkien, J. R. R." to "Tolkien, JRR".
210+
#
211+
# This is necessary because normal icu_collation_keyword
212+
# fields can't specify char_filter.
213+
self._analyzers["en_sort_author_analyzer"] = dict(
214+
tokenizer="keyword",
215+
filter=["en_sortable_filter"],
216+
char_filter=self.AUTHOR_CHAR_FILTER_NAMES,
217+
)
218+
219+
self._fields: dict[str, SearchMappingFieldType] = {
220+
"summary": BASIC_TEXT,
221+
"title": FILTERABLE_TEXT,
222+
"subtitle": FILTERABLE_TEXT,
223+
"series": FILTERABLE_TEXT,
224+
"classifications.term": FILTERABLE_TEXT,
225+
"author": FILTERABLE_TEXT,
226+
"publisher": FILTERABLE_TEXT,
227+
"imprint": FILTERABLE_TEXT,
228+
"presentation_ready": BOOLEAN,
229+
"sort_title": icu_collation_keyword(),
230+
"sort_author": sort_author_keyword(),
231+
"series_position": INTEGER,
232+
"work_id": INTEGER,
233+
"last_update_time": LONG,
234+
"published": LONG,
235+
"audience": keyword(),
236+
"language": keyword(),
237+
# Added in v6.
238+
"lane_priority_level": INTEGER,
239+
}
240+
241+
contributors = nested()
242+
contributors.add_property("display_name", FILTERABLE_TEXT)
243+
contributors.add_property("sort_name", FILTERABLE_TEXT)
244+
contributors.add_property("family_name", FILTERABLE_TEXT)
245+
contributors.add_property("role", keyword())
246+
contributors.add_property("lc", keyword())
247+
contributors.add_property("viaf", keyword())
248+
self._fields["contributors"] = contributors
249+
250+
licensepools = nested()
251+
licensepools.add_property("collection_id", INTEGER)
252+
licensepools.add_property("data_source_id", INTEGER)
253+
licensepools.add_property("availability_time", LONG)
254+
licensepools.add_property("available", BOOLEAN)
255+
licensepools.add_property("open_access", BOOLEAN)
256+
licensepools.add_property("suppressed", BOOLEAN)
257+
licensepools.add_property("licensed", BOOLEAN)
258+
licensepools.add_property("medium", keyword())
259+
# Added in v7.
260+
licensepools.add_property("last_updated", LONG)
261+
self._fields["licensepools"] = licensepools
262+
263+
identifiers = nested()
264+
identifiers.add_property("type", keyword())
265+
identifiers.add_property("identifier", keyword())
266+
self._fields["identifiers"] = identifiers
267+
268+
genres = nested()
269+
genres.add_property("scheme", keyword())
270+
genres.add_property("name", keyword())
271+
genres.add_property("term", keyword())
272+
genres.add_property("weight", FLOAT)
273+
self._fields["genres"] = genres
274+
275+
customlists = nested()
276+
customlists.add_property("list_id", INTEGER)
277+
customlists.add_property("first_appearance", LONG)
278+
customlists.add_property("featured", BOOLEAN)
279+
self._fields["customlists"] = customlists
280+
281+
def mapping_document(self) -> SearchMappingDocument:
282+
document = SearchMappingDocument()
283+
document.settings["analysis"] = dict(
284+
filter=dict(self._filters),
285+
char_filter=dict(self._char_filters),
286+
normalizer=dict(self._normalizers),
287+
analyzer=dict(self._analyzers),
288+
)
289+
# Index settings
290+
document.settings["index"] = {
291+
"number_of_shards": self.NUMBER_OF_SHARDS,
292+
"number_of_replicas": self.NUMBER_OF_REPLICAS,
293+
**self.SEARCH_SLOWLOG_THRESHOLDS,
294+
}
295+
document.properties = self._fields
296+
return document
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from palace.manager.search.revision import SearchSchemaRevision
2+
from palace.manager.search.v7 import SearchV7
3+
from palace.manager.search.v8 import SearchV8
4+
5+
6+
class TestSearchV8:
7+
def test_version(self):
8+
assert SearchV8().version == 8
9+
10+
def test_self_contained(self):
11+
"""v8 must not chain off any previous revision, so old revisions can be
12+
deleted once nothing in production uses them."""
13+
assert SearchV8.__bases__ == (SearchSchemaRevision,)
14+
15+
def test_pins_index_settings(self):
16+
"""v8 sets the expected index settings."""
17+
index = SearchV8().mapping_document().settings["index"]
18+
for setting in [
19+
"number_of_shards",
20+
"number_of_replicas",
21+
"search.slowlog.threshold.query.warn",
22+
"search.slowlog.threshold.query.info",
23+
"search.slowlog.threshold.fetch.warn",
24+
"search.slowlog.threshold.fetch.info",
25+
]:
26+
assert setting in index
27+
28+
def test_mapping_matches_v7(self):
29+
"""v8 is a faithful, self-contained copy of the v7 schema: same fields
30+
and same analysis settings. Only the index settings differ (v8 pins the
31+
shard count)."""
32+
v8_document = SearchV8().mapping_document()
33+
v7_document = SearchV7().mapping_document()
34+
35+
assert v8_document.serialize_properties() == v7_document.serialize_properties()
36+
assert v8_document.settings["analysis"] == v7_document.settings["analysis"]
37+
# v7 left the shard count to an inherited default; v8 sets it explicitly.
38+
assert "index" not in v7_document.settings
39+
assert "index" in v8_document.settings

0 commit comments

Comments
 (0)