-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdatabase.py
More file actions
247 lines (188 loc) · 8.16 KB
/
Copy pathdatabase.py
File metadata and controls
247 lines (188 loc) · 8.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import logging
import os
import sqlite3
import uuid
from typing import Any, Optional
from flask import Flask
from flask_alembic import Alembic
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event
from sqlalchemy.engine.interfaces import DBAPIConnection
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import QueuePool
from app.utilsGame import safe_join
# WAL doesn't really make sense since we want our database to be a single file. But the
# option is there
ENABLE_WAL = False
# Enable the pysqlite dirty fixes. You might need to disable them to generate a new
# Alembic revision/release
SQLITE_HACKS = True
class Base(DeclarativeBase):
"""Base Model that all Model classes that should be persisted have to extend."""
pass
# Some default options added to the gameServer.py@DefaultFlaskSettings
_sqlalchemy_args: dict[str, Any] = {
"model_class": Base,
"engine_options": {
# NullPool is more robust against wsgi server implementations, that use multiprocessing / os.fork
# https://docs.sqlalchemy.org/en/20/core/pooling.html#using-connection-pools-with-multiprocessing-or-os-fork
# But it comes with a performance penalty, therefore we use the static pool and have to make sure,
# that a connection is not forked to the child process.
"poolclass": QueuePool
}
}
if SQLITE_HACKS:
_sqlalchemy_args["engine_options"]["connect_args"] = {
# https://docs.python.org/3.12/library/sqlite3.html#transaction-control
"autocommit": sqlite3.LEGACY_TRANSACTION_CONTROL
#"isolation_level": "EXCLUSIVE" # EXCLUSIVE
}
# Init the DB
db: SQLAlchemy = SQLAlchemy(**_sqlalchemy_args)
_app: Optional[Flask] = None
_alembic: Optional[Alembic] = None
class ReverSimDatabase:
_alembic: Alembic | None = None
sqlite_hacks_enabled = False
@classmethod
def createDatabase(cls, app: Flask):
global _alembic, _app
_app = app
try:
# Ensure that the folder for the database exists
folder_database = safe_join(app.instance_path, "statistics")
os.makedirs(folder_database, exist_ok=True)
# Check that we have write permissions in the folder/volume
test_if_folder_is_writable_file = folder_database + "/test_writable.txt"
with open(test_if_folder_is_writable_file, 'tw') as f:
f.write("Hello World!")
# Get rid of the temporary file
os.remove(test_if_folder_is_writable_file)
except Exception:
logging.exception("Unable to create folder for database!")
db.init_app(app)
try:
_alembic = Alembic()
_alembic.init_app(app)
except Exception:
logging.exception("Alembic init failed: ")
# Create or upgrade the database
with app.app_context():
event.listen(db.engine, "checkout", cls.checkout)
ReverSimDatabase.enableSQLiteHacks()
db.create_all()
# Exit the app if the version is outdated
cls.check()
@staticmethod
def do_connect(dbapi_connection: DBAPIConnection, connection_record: Any):
connection_record.info["pid"] = os.getpid()
if SQLITE_HACKS:
# disable pysqlite's emitting of the BEGIN statement entirely.
# also stops it from emitting COMMIT before any DDL.
dbapi_connection.isolation_level = None # type: ignore
@staticmethod
def do_begin(conn: Any):
if SQLITE_HACKS:
assert conn.connection.dbapi_connection.isolation_level is None, \
"Expected the isolation_level to be None, since we are manually emitting our begin" # type: ignore
# emit our own BEGIN
# NOTE: When not in EXCLUSIVE mode, a transaction can later be upgraded to a
# write, in which case multiple reads are no longer possible as the other
# transaction might have performed a dirty read and the ACID principle would
# be violated. This is probably the reason, why this will not timeout other
# transactions but instead immediately throws an SQLITE_BUSY error.
conn.exec_driver_sql("BEGIN EXCLUSIVE")
@staticmethod
def checkout(dbapi_connection: Any, connection_record: Any, connection_proxy: Any):
assert connection_record.info["pid"] == os.getpid(), "pid mismatch, this connection belongs to a different process!"
@classmethod
def check(cls):
"""Check if the database is up to date ~~and exit the application otherwise~~"""
global _alembic
if _alembic is None:
logging.critical('-------------------------------------------------')
logging.critical(" Flask-Alembic should be initialized at this point!!!")
logging.critical(" Without Alembic we have to skip the database version check!")
logging.critical('-------------------------------------------------')
return "v?error"
currentVersion = None
latestVersion = None
try:
currentVersion = _alembic.current()
latestVersion = _alembic.heads()
assert len(currentVersion) <= 1, "ReverSim does not support Alembic upgrade branches yet"
assert len(latestVersion) <= 1, "ReverSim does not support Alembic upgrade branches yet"
if len(currentVersion) < 1:
logging.info("No Alembic version table found. The database was probably just created.")
_alembic.stamp() # Assume that the database was created with the latest ReverSim Code
return "v?"
if len(latestVersion) < 1:
logging.error("No Alembic upgrade files found. Please check your migrations/ directory.")
return "v?"
currentRevID = currentVersion[0].revision
latestRevID = latestVersion[0].revision
# If the current version does not match the latest version, we are out of date
if currentRevID != latestRevID:
logging.error('-------------------------------------------------')
logging.error(f' The database is out of date: {currentRevID} -> {latestRevID}, "{latestVersion[0].doc}"!')
logging.error(' Please read "doc/Database.md" to learn how you can upgrade!')
logging.error('-------------------------------------------------')
else:
logging.info(f"Current Alembic DB version: {currentRevID}, {currentVersion[0].doc}.")
return currentRevID
except Exception as e:
logging.exception(f'Alembic version detection failed: "{e}"')
return "v?"
@classmethod
def enableSQLiteHacks(cls):
"""The SQLite driver needs some special treatment to behave as expected.
Further information:
- https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#pysqlite-serializable
- [doc/Database.md](doc/Database.md)
"""
if not SQLITE_HACKS or cls.sqlite_hacks_enabled:
return
logging.info("Enabling the SQLite modifications to ensure a working transactional scope")
event.listen(db.engine, "connect", cls.do_connect)
event.listen(db.engine, "begin", cls.do_begin)
db.engine.dispose(close=False)
with db.engine.connect() as connection:
dbapi_connection = connection.connection.dbapi_connection
assert dbapi_connection is not None, "Expecting an initialized dbapi_connection at this state..."
assert dbapi_connection.autocommit == sqlite3.LEGACY_TRANSACTION_CONTROL, \
("There is a long standing bug in the Pysqlite driver, which violates SQLites"
"ability to enforce the ACID principles. While it is fixed in theory with "
"Python 3.12, we still have to resort to the legacy transaction control, as"
"there is no way with the new `autocommit=False` settings to enforce "
"`BEGIN EXCLUSIVE`...")
dbapi_connection.isolation_level = None # type: ignore
cls.sqlite_hacks_enabled = True
@classmethod
def pre_db_connect(cls, dbapi_connection: DBAPIConnection, connection_record: Any):
""""""
# Turn on Write Ahead Logging
if ENABLE_WAL:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
class SanityVersion:
"""Add a version_id column to a db Model to fortify against race conditions.
SQLAlchemy has a mechanism to detect in memory race conditions. Add this Mixin to
any class where a race might be devastating. However this cannot protect against all
race conditions.
https://docs.sqlalchemy.org/en/20/orm/versioning.html
"""
version_id: Mapped[int] = mapped_column(nullable=False)
__mapper_args__ = { # type: ignore
"version_id_col": version_id,
"version_id_generator": lambda version: uuid.uuid4().hex, # type: ignore
}
# Database specific config (especially max string lengths)
LEN_SESSION_ID = 8
LEN_GROUP = 64
LEN_PHASE = 16
LEN_LEVEL_PATH = LEN_GROUP
LEN_LEVEL_TYPE = 12
LEN_GIT_HASH_S = 16
LEN_VERSION = 12
LEN_LEVEL = 1024