hexkit 4.1.1__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.
- {hexkit-4.1.1/src/hexkit.egg-info → hexkit-4.2.0}/PKG-INFO +3 -2
- {hexkit-4.1.1 → hexkit-4.2.0}/pyproject.toml +1 -1
- {hexkit-4.1.1/src/hexkit/providers/mongokafka → hexkit-4.2.0/src/hexkit/providers/mongodb/migrations}/__init__.py +18 -4
- hexkit-4.2.0/src/hexkit/providers/mongodb/migrations/_manager.py +403 -0
- hexkit-4.2.0/src/hexkit/providers/mongodb/migrations/_utils.py +380 -0
- hexkit-4.2.0/src/hexkit/providers/mongokafka/__init__.py +37 -0
- hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/__init__.py +36 -0
- hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/config.py +36 -0
- hexkit-4.1.1/src/hexkit/providers/mongokafka/provider.py → hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/daopub.py +2 -18
- hexkit-4.2.0/src/hexkit/providers/mongokafka/provider/persistent_pub.py +256 -0
- {hexkit-4.1.1 → hexkit-4.2.0/src/hexkit.egg-info}/PKG-INFO +3 -2
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit.egg-info/SOURCES.txt +7 -1
- {hexkit-4.1.1 → hexkit-4.2.0}/LICENSE +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/MANIFEST.in +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/README.md +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/setup.cfg +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/__main__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/base.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/config.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/correlation.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/log.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/protocols/objstorage.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/akafka/testutils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/mongodb/provider.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/provider.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/py.typed +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit/utils.py +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit.egg-info/requires.txt +0 -0
- {hexkit-4.1.1 → hexkit-4.2.0}/src/hexkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: hexkit
|
|
3
|
-
Version: 4.
|
|
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
|

|
|
47
48
|
[](https://pypi.python.org/pypi/hexkit/)
|
|
@@ -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
|
-
"""
|
|
16
|
+
"""Database migration tools for MongoDB"""
|
|
18
17
|
|
|
19
|
-
from .
|
|
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__ = [
|
|
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)
|