-
Notifications
You must be signed in to change notification settings - Fork 3
Search: Adds re-try mechanism while re-indexing #2583
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ | |
| searchable_sqlalchemy_models, | ||
| ) | ||
| from sqlalchemy import text | ||
| from sqlalchemy.exc import OperationalError | ||
| from sqlalchemy.orm import undefer | ||
|
|
||
|
|
||
|
|
@@ -27,6 +28,10 @@ | |
| from sqlalchemy.orm import DeclarativeBase, Session | ||
|
|
||
|
|
||
| #: retries per model on a transient reindex conflict | ||
| REINDEX_MAX_ATTEMPTS = 3 | ||
|
|
||
|
|
||
| class SearchApp(morepath.App): | ||
| """ Provides elasticsearch and postgres integration for | ||
| :class:`onegov.core.framework.Framework` based applications. | ||
|
|
@@ -234,36 +239,75 @@ def perform_reindex(self, dispose_session: bool = True) -> None: | |
| self.fts_indexer.delete_search_index(session) | ||
|
|
||
| def reindex_model(model: type[Base]) -> None: | ||
| """ Load all database objects and index them. """ | ||
| session = self.session() | ||
| try: | ||
| query = session.query(model).options(undefer('*')) | ||
| query = apply_searchable_polymorphic_filter( | ||
| query, | ||
| model, | ||
| order_by_polymorphic_identity=True | ||
| ) | ||
| """ Load all database objects and index them. | ||
|
|
||
| Since we run under ``SERIALIZABLE`` isolation, a concurrent write | ||
| on a busy site can make the bulk upsert fail with a transaction | ||
| rollback error (e.g. "could not serialize access due to concurrent | ||
| update"). Such conflicts are transient, so we retry the whole | ||
| model, mirroring how normal requests handle conflicts. | ||
| """ | ||
| for attempt in range(1, REINDEX_MAX_ATTEMPTS + 1): | ||
| session = self.session() | ||
| try: | ||
| query = session.query(model).options(undefer('*')) | ||
| query = apply_searchable_polymorphic_filter( | ||
| query, model, order_by_polymorphic_identity=True | ||
| ) | ||
|
|
||
| # NOTE: we bypass the normal transaction machinery for speed | ||
| self.fts_indexer.process(( | ||
| task | ||
| for obj in query | ||
| # we bypass the normal transaction machinery for speed | ||
| self.fts_indexer.process( | ||
| ( | ||
| task | ||
| for obj in query | ||
| if ( | ||
| task := self.fts_orm_events.index_task( | ||
| schema, obj | ||
| ) # type: ignore[arg-type] | ||
| ) | ||
| is not None | ||
| ), | ||
| session, | ||
| ) | ||
| session.execute(text('COMMIT')) | ||
| return | ||
|
|
||
| except OperationalError as e: | ||
| # Error Class 40 (transaction rollback, e.g. serialization | ||
| # failure or deadlock) is transient, so retry the model. | ||
| orig = getattr(e, 'orig', None) | ||
| sqlstate = getattr(orig, 'sqlstate', None) | ||
| if ( | ||
| task := self.fts_orm_events.index_task(schema, obj) # type: ignore[arg-type] | ||
| ) is not None | ||
| ), session) | ||
| session.execute(text('COMMIT')) | ||
|
|
||
| except Exception: | ||
| index_log.info( | ||
| f"Error indexing model '{model.__name__}' " | ||
| f"in schema {schema}", | ||
| exc_info=True | ||
| ) | ||
| finally: | ||
| session.invalidate() | ||
| if session.bind and hasattr(session.bind, 'dispose'): | ||
| session.bind.dispose() | ||
| sqlstate | ||
| and sqlstate.startswith('40') | ||
| and attempt < REINDEX_MAX_ATTEMPTS | ||
| ): | ||
| index_log.info( | ||
| f"Conflict while indexing model '{model.__name__}'" | ||
| f' in schema {schema}, retrying ' | ||
| f'(attempt {attempt}/{REINDEX_MAX_ATTEMPTS})' | ||
| ) | ||
| continue | ||
|
|
||
| index_log.error( | ||
| f"Error indexing model '{model.__name__}' " | ||
| f'in schema {schema}', | ||
| exc_info=True, | ||
| ) | ||
| return | ||
|
|
||
| except Exception: | ||
| index_log.error( | ||
| f"Error indexing model '{model.__name__}' " | ||
| f'in schema {schema}', | ||
| exc_info=True, | ||
| ) | ||
| return | ||
|
|
||
| finally: | ||
| session.invalidate() | ||
| if session.bind and hasattr(session.bind, 'dispose'): | ||
| session.bind.dispose() | ||
|
Comment on lines
+307
to
+310
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kind of missed that this This is also the only reason it was working, even though I thought it shouldn't, since invalidating the session and disposing of the connection implicitly rolls back the transaction and starts a new transaction in the next iteration. You could try moving this out of the loop and changing the returns inside the loop to You would need to be a little careful about A different approach, that preserves the old semantics of cleanup always happening, even with |
||
|
|
||
| with ThreadPoolExecutor() as executor: | ||
| executor.map(reindex_model, self.indexable_base_models()) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not quite sure what will happen to the raw connection here with a real database error, since we no longer use a
session.begin_nestedin the indexer.I think it will probably be in an invalid state and will refuse to execute any further queries. So this fix wouldn't actually help right now. I'm not sure if doing a
session.execute('ROLLBACK')here would be safe, since we're on a different thread we should be getting our own session, but I'm not sure if that means we're necessarily also getting our own raw connection.Maybe manually doing
tx = session.begin_nested()before processing the indexer tasks and then callingtx.rollback()in this branch would work, but I'm not sure.Your mocked test case doesn't exercise the details here properly, since you're completely bypassing the indexer, when you emit an exception, so to the database it still looks like you only emitted the query once, and that it succeeded.
I'm not sure if there is a robust way to test this. You might be able to trigger a real serialization failure reliably by using two threads and some events for synchronization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Savepoints/Nested transactions will probably not work, since the start of the main transaction will be the same, so the database will still consider the retry a serialization failure. We probably need to hope here that SQLAlchemy properly isolates the connections in the pool and that a manual
session.execute('ROLLBACK')is safe.