hexkit 4.1.0__tar.gz → 4.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {hexkit-4.1.0/src/hexkit.egg-info → hexkit-4.2.0}/PKG-INFO +3 -2
  2. {hexkit-4.1.0 → hexkit-4.2.0}/pyproject.toml +1 -1
  3. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/log.py +5 -1
  4. {hexkit-4.1.0/src/hexkit/providers/mongokafka → hexkit-4.2.0/src/hexkit/providers/mongodb/migrations}/__init__.py +18 -4
  5. hexkit-4.2.0/src/hexkit/providers/mongodb/migrations/_manager.py +403 -0
  6. hexkit-4.2.0/src/hexkit/providers/mongodb/migrations/_utils.py +380 -0
  7. hexkit-4.2.0/src/hexkit/providers/mongokafka/__init__.py +37 -0
  8. hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/__init__.py +36 -0
  9. hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/config.py +36 -0
  10. hexkit-4.1.0/src/hexkit/providers/mongokafka/provider.py → hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/daopub.py +2 -18
  11. hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/persistent_pub.py +256 -0
  12. {hexkit-4.1.0 → hexkit-4.2.0/src/hexkit.egg-info}/PKG-INFO +3 -2
  13. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit.egg-info/SOURCES.txt +7 -1
  14. {hexkit-4.1.0 → hexkit-4.2.0}/LICENSE +0 -0
  15. {hexkit-4.1.0 → hexkit-4.2.0}/MANIFEST.in +0 -0
  16. {hexkit-4.1.0 → hexkit-4.2.0}/README.md +0 -0
  17. {hexkit-4.1.0 → hexkit-4.2.0}/setup.cfg +0 -0
  18. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/__init__.py +0 -0
  19. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/__main__.py +0 -0
  20. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/base.py +0 -0
  21. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/config.py +0 -0
  22. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/correlation.py +0 -0
  23. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/custom_types.py +0 -0
  24. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/__init__.py +0 -0
  25. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/dao.py +0 -0
  26. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/daopub.py +0 -0
  27. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/daosub.py +0 -0
  28. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/eventpub.py +0 -0
  29. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/eventsub.py +0 -0
  30. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/protocols/objstorage.py +0 -0
  31. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/__init__.py +0 -0
  32. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  33. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/config.py +0 -0
  34. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  35. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  36. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  37. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  38. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  39. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  40. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/akafka/testutils.py +0 -0
  41. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  42. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/mongodb/provider.py +0 -0
  43. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  44. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  45. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/__init__.py +0 -0
  46. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/provider.py +0 -0
  47. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  48. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  49. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  50. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  51. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  52. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
  53. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
  54. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  55. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  56. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/testing/__init__.py +0 -0
  57. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  58. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/py.typed +0 -0
  59. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit/utils.py +0 -0
  60. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  61. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit.egg-info/requires.txt +0 -0
  62. {hexkit-4.1.0 → hexkit-4.2.0}/src/hexkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
@@ -42,6 +42,7 @@ Provides-Extra: test
42
42
  Requires-Dist: hexkit[test-akafka,test-mongodb,test-s3]; extra == "test"
43
43
  Provides-Extra: all
44
44
  Requires-Dist: hexkit[test]; extra == "all"
45
+ Dynamic: license-file
45
46
 
46
47
  ![tests](https://github.com/ghga-de/hexkit/actions/workflows/tests.yaml/badge.svg)
47
48
  [![PyPI version shields.io](https://img.shields.io/pypi/v/hexkit.svg)](https://pypi.python.org/pypi/hexkit/)
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  ]
25
25
  name = "hexkit"
26
- version = "4.1.0"
26
+ version = "4.2.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "pydantic >=2, <3",
@@ -118,13 +118,17 @@ class JsonFormatter(Formatter):
118
118
  output["details"] = log_record["details"]
119
119
 
120
120
  if log_record["exc_info"]:
121
- exc_type, exc_value = log_record["exc_info"][:2]
121
+ exc_info = log_record["exc_info"]
122
+ exc_type, exc_value = exc_info[:2]
122
123
  exception = {
123
124
  "type": exc_type.__name__,
124
125
  "message": str(exc_value),
125
126
  }
126
127
  if self._include_traceback:
127
128
  exc_text = log_record["exc_text"]
129
+ if not exc_text:
130
+ # if there is not pre-formatted cached traceback, create it
131
+ exc_text = super().formatException(exc_info)
128
132
  if exc_text:
129
133
  exception["traceback"] = exc_text
130
134
  output["exception"] = exception
@@ -12,10 +12,24 @@
12
12
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
- #
16
15
 
17
- """An implementation of the DaoPublisherFactoryProtocol based on MongoDB and Apache Kafka."""
16
+ """Database migration tools for MongoDB"""
18
17
 
19
- from .provider import MongoKafkaConfig, MongoKafkaDaoPublisherFactory
18
+ from ._manager import (
19
+ MigrationConfig,
20
+ MigrationManager,
21
+ MigrationMap,
22
+ MigrationStepError,
23
+ )
24
+ from ._utils import Document, MigrationDefinition, Reversible, validate_doc
20
25
 
21
- __all__ = ["MongoKafkaConfig", "MongoKafkaDaoPublisherFactory"]
26
+ __all__ = [
27
+ "Document",
28
+ "MigrationConfig",
29
+ "MigrationDefinition",
30
+ "MigrationManager",
31
+ "MigrationMap",
32
+ "MigrationStepError",
33
+ "Reversible",
34
+ "validate_doc",
35
+ ]
@@ -0,0 +1,403 @@
1
+ # Copyright 2021 - 2024 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Tools to run database migrations in services"""
16
+
17
+ import logging
18
+ from asyncio import sleep
19
+ from collections.abc import Mapping
20
+ from contextlib import asynccontextmanager, suppress
21
+ from datetime import datetime, timezone
22
+ from time import perf_counter, time
23
+ from typing import Literal, Optional, TypedDict
24
+
25
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
26
+ from pydantic import Field
27
+ from pymongo.errors import DuplicateKeyError
28
+
29
+ from hexkit.providers.mongodb import MongoDbConfig
30
+
31
+ from ._utils import MigrationDefinition, Reversible
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ MigrationCls = type[MigrationDefinition]
36
+ MigrationMap = Mapping[int, MigrationCls]
37
+
38
+
39
+ def now_as_utc() -> datetime:
40
+ """Return the current timestamp with UTC timezone"""
41
+ return datetime.now().astimezone(tz=timezone.utc)
42
+
43
+
44
+ def duration_in_ms(duration: float) -> int:
45
+ """Returns the duration (seconds) expressed as milliseconds"""
46
+ return int(duration * 1000)
47
+
48
+
49
+ class MigrationConfig(MongoDbConfig):
50
+ """Minimal configuration required to run the migration process."""
51
+
52
+ db_version_collection: str = Field(
53
+ ...,
54
+ description="The name of the collection containing DB version information for this service",
55
+ examples=["ifrsDbVersions"],
56
+ )
57
+ migration_wait_sec: int = Field(
58
+ ...,
59
+ description="The number of seconds to wait before checking the DB version again",
60
+ examples=[5, 30, 180],
61
+ )
62
+ migration_max_wait_sec: Optional[int] = Field(
63
+ default=None,
64
+ description="The maximum number of seconds to wait for migrations to complete"
65
+ + " before raising an error.",
66
+ examples=[None, 300, 600, 3600],
67
+ )
68
+
69
+
70
+ class DbVersionRecord(TypedDict):
71
+ """Model containing information about DB versions and how they were achieved."""
72
+
73
+ version: int
74
+ completed: datetime
75
+ backward: bool
76
+ total_duration_ms: int
77
+
78
+
79
+ class MigrationStepError(RuntimeError):
80
+ """Raised when a specific migration step fails, e.g. migrating from v4 to v5"""
81
+
82
+ def __init__(self, *, migration_class: MigrationCls, backward: bool):
83
+ apply = "unapply" if backward else "apply"
84
+ version = migration_class.version
85
+ name = migration_class.__name__
86
+ msg = f"Unable to {apply} DB version {version} ({name})"
87
+ super().__init__(msg)
88
+
89
+
90
+ class DbLockError(RuntimeError):
91
+ """Raised when the DB lock can't be released or acquired due to an error."""
92
+
93
+ def __init__(self, *, op: Literal["acquire", "release"], coll_name: str):
94
+ msg = f"Failed to {op} the lock in collection {coll_name} due to an error."
95
+ super().__init__(msg)
96
+
97
+
98
+ class DbVersioningInitError(RuntimeError):
99
+ """Raised when DB versioning initialization fails due to an error."""
100
+
101
+ def __init__(self):
102
+ msg = "DB versioning initialization failed, likely due to a database error."
103
+ super().__init__(msg)
104
+
105
+
106
+ class MigrationTimeoutError(RuntimeError):
107
+ """Raised when running migrations exceed the configured `migration_max_wait_sec`."""
108
+
109
+ def __init__(self, *, limit: int):
110
+ msg = f"Timeout occurred - migration duration exceeded {limit} seconds."
111
+ super().__init__(msg)
112
+
113
+
114
+ def _get_db_version_from_records(version_docs: list[DbVersionRecord]) -> int:
115
+ """Gets the current DB version from the documents found in the version collection."""
116
+ # Make sure we know what the latest version is, not just the max
117
+ return max(
118
+ version_docs,
119
+ key=lambda doc: (doc["completed"], doc["version"]),
120
+ default={"version": 0},
121
+ )["version"]
122
+
123
+
124
+ class MigrationManager:
125
+ """Top-level logic for ensuring the database is updated before running the service.
126
+
127
+ The `migrate_or_wait` method must be called before any instance of the service
128
+ begins its main execution loop.
129
+
130
+ Version 1 is reserved for the framework as a way to mark when versioning was added.
131
+
132
+ Example usage:
133
+ ```
134
+ from my_service.config import Config # inherits from MongoDbConfig
135
+ from hexkit.providers.mongodb.migrations import MigrationManager
136
+ from my_service.migrations import V2Migration, V3Migration
137
+
138
+ DB_VERSION = 2 # the current expected DB version
139
+ MY_MIGRATION_MAP = {2: V2Migration, 3: V3Migration} # etc.
140
+
141
+ def migrate_my_service():
142
+ # Called before starting my_service
143
+ config = Config()
144
+
145
+ async with MigrationManager(config, DB_VERSION, MY_MIGRATION_MAP) as mm:
146
+ await mm.migrate_or_wait()
147
+ ```
148
+ """
149
+
150
+ client: AsyncIOMotorClient
151
+ db: AsyncIOMotorDatabase
152
+
153
+ def __init__(
154
+ self,
155
+ config: MigrationConfig,
156
+ target_version: int,
157
+ migration_map: MigrationMap,
158
+ ):
159
+ """Instantiate the MigrationManager.
160
+
161
+ Args
162
+ - `config`: Config containing db connection str and lock/db versioning collections
163
+ - `target_version`: Which version the db needs to be at for this version of the service
164
+ - `migration_map`: A dict with the MigrationDefinition class for each db version
165
+ """
166
+ if target_version < 1:
167
+ raise ValueError("Expected database version must be 1 or greater")
168
+
169
+ self.config = config
170
+ self.target_ver = target_version
171
+ self.migration_map = migration_map
172
+ self._lock_acquired = False
173
+ self._entered = False
174
+ self._backward: bool = False
175
+
176
+ async def __aenter__(self):
177
+ """Set up database client and database reference"""
178
+ self.client = AsyncIOMotorClient(
179
+ str(self.config.mongo_dsn.get_secret_value()),
180
+ tz_aware=True,
181
+ )
182
+ self.db = self.client[self.config.db_name]
183
+ self._entered = True
184
+ return self
185
+
186
+ async def __aexit__(self, exc_type_, exc_value, exc_tb):
187
+ """Release DB lock and close/remove database client"""
188
+ await self._release_db_lock()
189
+ self.client.close()
190
+
191
+ async def _get_version_docs(self) -> list[DbVersionRecord]:
192
+ """Gets the DB version information from the database."""
193
+ collection = self.db[self.config.db_version_collection]
194
+ # use a filter to avoid picking up the lock doc, just in case
195
+ version_docs = []
196
+ async for doc in collection.find({"_id": {"$ne": 0}}):
197
+ doc.pop("_id")
198
+ version_docs.append(DbVersionRecord(**doc)) # type: ignore
199
+ return version_docs
200
+
201
+ @asynccontextmanager
202
+ async def _lock_db(self):
203
+ await self._acquire_db_lock()
204
+ try:
205
+ yield
206
+ finally:
207
+ await self._release_db_lock()
208
+
209
+ async def _acquire_db_lock(self) -> None:
210
+ """Try to acquire the lock on the DB and return the result.
211
+
212
+ Logs and raises any error that occurs while updating the lock document.
213
+ """
214
+ if self._lock_acquired:
215
+ log.debug("Database lock already acquired")
216
+ return
217
+ coll_name = self.config.db_version_collection
218
+ try:
219
+ version_coll = self.db[coll_name]
220
+ with suppress(DuplicateKeyError):
221
+ await version_coll.insert_one(
222
+ {
223
+ "_id": 0,
224
+ "lock_acquired": True,
225
+ "acquired_at": now_as_utc(),
226
+ }
227
+ )
228
+ self._lock_acquired = True
229
+ log.info("Database lock acquired")
230
+ except BaseException as exc:
231
+ error = DbLockError(op="acquire", coll_name=coll_name)
232
+ log.error(error)
233
+ raise error from exc
234
+
235
+ if not self._lock_acquired:
236
+ log.debug("Did not acquire DB lock in collection %s", coll_name)
237
+
238
+ async def _release_db_lock(self) -> None:
239
+ """Release the DB lock by deleting the lock document.
240
+
241
+ Logs and re-raises any errors that occur during the update.
242
+ """
243
+ if not self._lock_acquired:
244
+ log.debug("Database lock already released")
245
+ return
246
+ coll_name = self.config.db_version_collection
247
+ try:
248
+ version_coll = self.db[coll_name]
249
+ await version_coll.find_one_and_delete({"lock_acquired": True})
250
+ self._lock_acquired = False
251
+ except BaseException as exc:
252
+ error = DbLockError(op="release", coll_name=coll_name)
253
+ log.critical(error)
254
+ raise error from exc
255
+ log.info("Database lock released")
256
+
257
+ async def _record_migration(self, *, version: int, total_duration_ms: int):
258
+ """Insert a DbVersionRecord with processing information"""
259
+ record = DbVersionRecord(
260
+ version=version,
261
+ completed=now_as_utc(),
262
+ backward=self._backward,
263
+ total_duration_ms=total_duration_ms,
264
+ )
265
+ version_collection = self.db[self.config.db_version_collection]
266
+ await version_collection.insert_one(record)
267
+
268
+ async def _initialize_versioning(self) -> bool:
269
+ """Create and acquire the DB lock, then add the versioning collection.
270
+
271
+ Returns `True` if setup was performed, else `False`.
272
+ """
273
+ init_start = time()
274
+ async with self._lock_db():
275
+ if self._lock_acquired:
276
+ # Initialize db version collection
277
+ await self._record_migration(
278
+ version=1,
279
+ total_duration_ms=duration_in_ms(time() - init_start),
280
+ )
281
+ return True
282
+ return False
283
+
284
+ def _get_version_sequence(self, *, current_ver: int) -> list[int]:
285
+ """Return an ordered list of the version migrations to apply/unapply"""
286
+ # In forward case, we don't need to apply current ver
287
+ # in backward case, we don't want to unapply the target ver
288
+ step_range = (
289
+ range(current_ver, self.target_ver, -1)
290
+ if self._backward
291
+ else range(current_ver + 1, self.target_ver + 1)
292
+ )
293
+ steps = list(step_range)
294
+ return steps
295
+
296
+ def _fetch_migration_cls(self, version: int) -> MigrationCls:
297
+ """Return the stored migration for the specified version.
298
+
299
+ Raise an error if the class doesn't exist or doesn't implement unapply when needed.
300
+ """
301
+ try:
302
+ migration_cls = self.migration_map[version]
303
+ if self._backward and not issubclass(migration_cls, Reversible):
304
+ raise RuntimeError(
305
+ f"Planning to unapply migration v{version}, but"
306
+ + f" it doesn't subclass `{Reversible.__name__}`!"
307
+ )
308
+ return migration_cls
309
+ except KeyError as err:
310
+ migration_type = "backward" if self._backward else "forward"
311
+ raise NotImplementedError(
312
+ f"No {migration_type} migration implemented for version {version}"
313
+ ) from err
314
+
315
+ async def _perform_migrations(self, *, current_ver: int):
316
+ """Migrate forward or backward to reach target DB version.
317
+
318
+ Raises `MigrationError` if unsuccessful.
319
+ """
320
+ ver_sequence = self._get_version_sequence(current_ver=current_ver)
321
+ migrations = [self._fetch_migration_cls(ver) for ver in ver_sequence]
322
+
323
+ # Execute & time each migration in order to get to the target DB version
324
+ for migration_cls in migrations:
325
+ try:
326
+ # Determine if this is the last migration to apply/unapply
327
+ is_final_migration = migration_cls.version == ver_sequence[-1]
328
+
329
+ # instantiate MigrationDefinition
330
+ migration = migration_cls(
331
+ db=self.db,
332
+ unapplying=self._backward,
333
+ is_final_migration=is_final_migration,
334
+ )
335
+
336
+ # Call apply/unapply based on migration type
337
+ await migration.unapply() if self._backward else await migration.apply()
338
+ except BaseException as exc:
339
+ error = MigrationStepError(
340
+ migration_class=migration_cls,
341
+ backward=self._backward,
342
+ )
343
+ log.critical(error)
344
+ raise error from exc
345
+
346
+ async def _migrate_db(self) -> bool:
347
+ """Ensure the database is up to date before running the actual app.
348
+
349
+ If the database is already up to date, no changes are made. If the database is
350
+ out of date, migration code is executed to make the database current.
351
+
352
+ Returns True if migrations are finished or up-to-date and False otherwise.
353
+ """
354
+ version_docs = await self._get_version_docs()
355
+ version = _get_db_version_from_records(version_docs)
356
+
357
+ if version == 0:
358
+ try:
359
+ init_complete = await self._initialize_versioning()
360
+ except BaseException as exc:
361
+ error = DbVersioningInitError()
362
+ log.critical(error)
363
+ raise error from exc
364
+ if not init_complete:
365
+ return False
366
+ version = 1
367
+
368
+ if version == self.target_ver:
369
+ # DB is up to date, run service
370
+ return True
371
+
372
+ # DB version is not what it should be: acquire lock and migrate
373
+ async with self._lock_db():
374
+ if not self._lock_acquired:
375
+ return False
376
+
377
+ if version > self.target_ver:
378
+ self._backward = True
379
+
380
+ start = time()
381
+ await self._perform_migrations(current_ver=version)
382
+ duration_ms = duration_in_ms(time() - start)
383
+
384
+ # record the db version
385
+ await self._record_migration(
386
+ version=self.target_ver,
387
+ total_duration_ms=duration_ms,
388
+ )
389
+ return True
390
+
391
+ async def migrate_or_wait(self):
392
+ """Try to migrate the database or wait until migrations are completed."""
393
+ if not self._entered:
394
+ raise RuntimeError("MigrationManager must be used as a context manager")
395
+
396
+ # need to implement some kind of total time limit, warning logging, etc. later
397
+ start_time = perf_counter()
398
+ limit = self.config.migration_max_wait_sec
399
+ while not await self._migrate_db():
400
+ elapsed = perf_counter() - start_time
401
+ if limit and elapsed > limit:
402
+ raise MigrationTimeoutError(limit=limit)
403
+ await sleep(self.config.migration_wait_sec)