Skip to content

feat(search): check the index schema on startup and refuse breaking changes - #3197

Merged
fschade merged 25 commits into
mainfrom
feat/search-schema-change-handling
Sep 1, 2026
Merged

fschade merged 25 commits into
mainfrom
feat/search-schema-change-handling

Conversation

@dschmidt

@dschmidt dschmidt commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3345 (base refactor/search-mapping is #3345's branch, so this PR shows only its own commits; retarget to main once #3345 merges, both ship together). Refs #3092.

Two mechanisms keep the index schema and the code in sync.

Versioned indices (from #3345). The index name carries search.SchemaVersion (opencloud-resource-v<N>, bleve-v<N>). An intended breaking change bumps the constant: the service targets a fresh, empty index and leaves the old one in place. No migration or index-to-index reindex; content lives only in the index and the files are the source of truth, so opencloud search index --all-spaces --force-rescan rebuilds everything.

Startup schema check (this PR). The bump is manual, so this is the safety net for a schema change made without one. On startup both engines diff the existing index schema against the schema generated from code, via one shared recursive classifier:

  • equal: start normally.
  • additive (new fields, no indexed data): applied in place, no bump. OpenSearch PUT _mapping; bleve persists the code mapping (SetInternal + reopen). A startup warning lists the new fields (documents from before the upgrade lack them until re-indexed).
  • breaking (changed types/analyzers, removed/renamed fields, new fields that already hold data): refuse to start. Versioning means a released instance never reaches this; it fires in development when the mapping changes breakingly without a bump, so the error is developer-facing (bump SchemaVersion or revert) and lists the diff.

Why this shape:

  • The classifier is the only oracle; PUT _mapping is just the apply step (its merge semantics hide removals/renames), so it must not judge.
  • bleve keeps no schema trace for dynamically indexed fields, so the classifier also checks idx.Fields(): a now-explicit field that already holds dynamic data of unknown shape is breaking (no search beats wrong search).
  • No migration framework or blue-green: versioning gives the fresh index, a rescan repopulates it.

Known and accepted:

  • Non-JSON 5xx proxy bodies lose their status code in opensearch-go error parsing (irrelevant while every non-conflict error is fatal).
  • Trashed items are absent after a rebuild until restored (the rescan walks only the live tree; docs caveat for Search engine schema update documentation // migration #3092).
  • The bleve datapath must not be shared between processes; a second opener now fails after 5s (bolt_timeout) instead of hanging.

Deliberate consequences (not bugs):

  • Additive upgrades are one-way within a version: once a release widened the schema, the previous release sees a stored-only field and refuses to start. Roll forward or rebuild; refusing both directions keeps mismatched schemas from serving queries.
  • A new custom analyzer classifies as breaking even if only a new field uses it (OpenSearch can't change analysis on an open index, and both engines must match).
  • Removing/renaming a field is breaking by design; tolerating removals would make renames undetectable.

@codacy-production

codacy-production Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics -51 duplication

Metric Results
Duplication -51

View in Codacy

🟢 Coverage 67.84% diff coverage · +0.12% coverage variation

Metric Results
Coverage variation ✅ +0.12% coverage variation (-1.00%)
Diff coverage ✅ 67.84% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (d38fbc8) 85544 19909 23.27%
Head commit (0e2e287) 85770 (+226) 20064 (+155) 23.39% (+0.12%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#3197) 342 232 67.84%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@dschmidt
dschmidt requested review from aduffeck and fschade July 29, 2026 21:14
@dschmidt
dschmidt marked this pull request as draft July 29, 2026 21:14
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch from a1b0535 to c7340ac Compare August 18, 2026 16:17
@dschmidt
dschmidt changed the base branch from tmp/refactor-search-mapping to refactor/search-mapping August 18, 2026 16:25
@dschmidt
dschmidt marked this pull request as ready for review August 19, 2026 08:43
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch from 8f27800 to d05047b Compare August 29, 2026 07:52
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch from d05047b to 29b0b27 Compare August 29, 2026 07:53
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch 2 times, most recently from 68cf5b1 to e9fc6ae Compare August 29, 2026 11:12
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch from e9fc6ae to c70d884 Compare August 29, 2026 11:27
@dschmidt
dschmidt force-pushed the feat/search-schema-change-handling branch from c70d884 to 43d41fa Compare August 31, 2026 12:34
Base automatically changed from refactor/search-mapping to main August 31, 2026 13:12
…hanges

Both engines now diff the stored/live index schema against the schema
generated from code when the service starts. A shared recursive
classifier in the mapping package is the single oracle:

- equal: start normally.
- additive (new fields without any indexed data): applied in place.
  OpenSearch gets a PUT _mapping with the full code properties, bleve
  persists the code mapping into the index (SetInternal + reopen) so
  the new fields are properly typed immediately and later startups
  classify equal. A startup warning lists the new fields because
  documents indexed before the upgrade lack them until re-indexed.
- breaking (changed definitions or analyzers, removed or renamed
  fields, or new fields that already contain data of unknown form):
  refuse to start with an error describing the rebuild procedure
  (delete the index, start, run "opencloud search index --all-spaces")
  and the OC_EXCLUDE_RUN_SERVICES=search escape hatch.

PUT _mapping is deliberately only the apply mechanism, never the
judge: its merge semantics cannot see removals or renames and it
accepts in-place updatable param changes with an ack. bleve
additionally checks idx.Fields() so previously dynamically indexed
data (which leaves no schema trace in bleve) is caught, matching by
exact name and by path prefix.

While at it: the OpenSearch startup check runs with a real,
minute-bounded context instead of context.TODO(), bleve indexes are
opened with a 5s bolt_timeout so a second process fails fast instead
of hanging on the file lock, and the reversed errors.Is arguments in
bleve.NewIndex were fixed.

#3092
… delete step

Addresses the two Copilot review comments on the PR: the additive
opensearch log now matches the bleve warning (level and re-index hint),
and the refuse message spells out how to delete the index per engine
(DELETE /<name> vs removing the bleve directory).
… tests

- shorten the multi-line doc comments flagged as too verbose
- add reconcile_test.go: direct unit tests for Reconcile incl. the
  persisted-but-errored and classify-error branches (previously only
  reached indirectly through the engine integration tests)
- convert the 11 near-identical Classify It blocks to a DescribeTable
- export mapping.SortedUnionKeys and reuse it in bleve.compareKeysExcept
  instead of a copied union-of-keys block
- add Classification.AddBreaking to fold engine-specific breaking reasons and
  force the verdict, replacing the identical block in the bleve and opensearch
  Classify paths
The opensearch-go bump renamed the mapping-get accessor, the schema check
takes a context and a logger now, and the golden bleve mapping carries the
word-broken Name and Title.
The refuse specs use registered analyzers (fulltext is gone), the golden regenerates via UPDATE_GOLDEN, MappingGetResp grew an accessor, and the parity suite passes the new NewBackend signature.
Pins the shipped schema as a reviewable diff; regenerate with UPDATE_GOLDEN=1.
@fschade
fschade force-pushed the feat/search-schema-change-handling branch from 9341733 to e11ce95 Compare August 31, 2026 13:12
The failure runs the classifier on golden vs generated: additive means regenerate only, breaking means bump too.
@butonic

butonic commented Aug 31, 2026

Copy link
Copy Markdown
Member

🤔 so how do we migrate in k8s? we can update all pods. the old ones will answer from the old index, the new ones will automagically create a new _v3 index, which is empty. a change in a space will trigger a reindex of that space. Or the admin executes a reindex cmd manually ... so forward update.

search requests from new pods will go to the new index, from old pods to the old ... that is at least consistent.

@dschmidt

dschmidt commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor Author

🤔 so how do we migrate in k8s? we can update all pods. the old ones will answer from the old index, the new ones will automagically create a new _v3 index, which is empty. a change in a space will trigger a reindex of that space. Or the admin executes a reindex cmd manually ... so forward update.

search requests from new pods will go to the new index, from old pods to the old ... that is at least consistent.

Yes, you are right about all of that - BUT that's an already existing issue. Completely independent from my first refactor and also this one. This PR is just a mechanism to detect divergence and add fields to the index

A real waterproof approach would probably take care of migration, possibly with event replay for different versions, I don't know. I agree it would be nice to have, but totally out of scope right now

@fschade fschade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@fschade
fschade merged commit 268a449 into main Sep 1, 2026
63 checks passed
@fschade
fschade deleted the feat/search-schema-change-handling branch September 1, 2026 05:17
@openclouders openclouders mentioned this pull request Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants