userver: en/testsuite/databases/pgsql/control.py Source File
Loading...
Searching...
No Matches
control.py
1import concurrent.futures
2import contextlib
3import dataclasses
4import logging
5import pathlib
6import time
7import typing
8import warnings
9
10import psycopg2
11import psycopg2.extensions
12import psycopg2.extras
13
14from testsuite.environment import shell
15
16from . import connection, discover, exceptions, pool, service, testsuite_db
17from .exceptions import __tracebackhide__ # noqa: F401
18
19logger = logging.getLogger(__name__)
20
21CREATE_DATABASE_TEMPLATE = """
22CREATE DATABASE "{}" WITH TEMPLATE = template0
23ENCODING='UTF8' LC_COLLATE='C' LC_CTYPE='C'
24"""
25DROP_DATABASE_TEMPLATE = 'DROP DATABASE IF EXISTS "{}"'
26LIST_TABLES_SQL = """
27SELECT CONCAT(table_schema, '.', table_name)
28FROM information_schema.tables
29WHERE table_schema != 'information_schema' AND
30table_schema != 'pg_catalog' AND table_type = 'BASE TABLE'
31ORDER BY table_schema,table_name
32"""
33
34TRUNCATE_SQL_TEMPLATE = 'TRUNCATE TABLE {tables} RESTART IDENTITY'
35TRUNCATE_RETRIES = 5
36TRUNCATE_RETRY_DELAY = 0.005
37
38
39class BaseError(Exception):
40 pass
41
42
44 pass
45
46
47@dataclasses.dataclass(frozen=True)
48class PgQuery:
49 body: str
50 source: str
51 path: str | None
52
53
55 def __init__(self, conninfo: connection.PgConnectionInfo):
56 self._initialized = False
57 self._conninfo = conninfo
58 self._conn: psycopg2.extensions.connection | None = None
59 self._tables: list[str] | None = None
60 self._truncate_thread: None | (concurrent.futures.Future[None]) = None
61 self._executer = concurrent.futures.ThreadPoolExecutor(max_workers=1)
62
63 def initialize(self, cleanup_exclude_tables: frozenset[str]):
64 if self._initialized:
65 return
66 cursor = self.conn.cursor()
67 with contextlib.closing(cursor):
68 cursor.execute(LIST_TABLES_SQL)
69 self._tables = [
70 table[0]
71 for table in cursor
72 if table[0] not in cleanup_exclude_tables
73 ]
74
75 self._initialized = True
76
77 @property
78 def conninfo(self) -> connection.PgConnectionInfo:
79 """returns
80 :py:class:`testsuite.databases.pgsql.connection.PgConnectionInfo`
81 """
82 return self._conninfo
83
84 @property
85 def conn(self) -> psycopg2.extensions.connection:
86 """:returns: :py:class:`psycopg2.extensions.connection`"""
87 if self._conn and self._conn.closed:
88 warnings.warn(
89 'Postgresql connection to {} was unexpectedly closed'.format(
90 self.conninfo.get_uri(),
91 ),
92 )
93 self._conn = None
94 if not self._conn:
95 self._conn = psycopg2.connect(self.conninfo.get_uri())
96 # TODO: remove autocommit, see TAXIDATA-2467
97 self._conn.autocommit = True
98 return self._conn
99
100 def cursor(self, **kwargs) -> psycopg2.extensions.cursor:
101 """:returns: :py:class:`psycopg2.extensions.cursor`"""
102 return self.conn.cursor(**kwargs)
103
104 def dict_cursor(self, **kwargs) -> psycopg2.extensions.cursor:
105 """Returns dictionary cursor, see psycopg2.extras.DictCursor
106
107 :returns: :py:class:`psycopg2.extensions.cursor`
108 """
109 kwargs['cursor_factory'] = psycopg2.extras.DictCursor
110 return self.cursor(**kwargs)
111
112 def apply_queries(self, queries: typing.Iterable[PgQuery]) -> None:
113 """Apply queries to database"""
114 cursor = self.cursor()
115 with contextlib.closing(cursor):
116 if self._truncate_thread:
117 self._truncate_thread.result()
118 self._truncate_thread = None
119 else:
120 self._try_truncate_tables(cursor)
121 for query in queries:
122 self._apply_query(cursor, query)
123
124 def close(self):
125 self._executer.shutdown()
126
127 def schedule_truncation(self):
128 def truncate():
129 cursor = self.cursor()
130 with contextlib.closing(cursor):
131 self._try_truncate_tables(cursor)
132
133 assert not self._truncate_thread
134 self._truncate_thread = self._executer.submit(truncate)
135
136 def _try_truncate_tables(self, cursor) -> None:
137 for _ in range(TRUNCATE_RETRIES):
138 try:
139 self._truncate_tables(cursor)
140 break
141 except psycopg2.extensions.TransactionRollbackError as exc:
142 logger.warning('Truncate table failed: %r', exc)
143 time.sleep(TRUNCATE_RETRY_DELAY)
144 else:
145 self._truncate_tables(cursor)
146
147 def _truncate_tables(self, cursor) -> None:
148 if self._tables:
149 cursor.execute(
150 TRUNCATE_SQL_TEMPLATE.format(tables=','.join(self._tables)),
151 )
152
153 @staticmethod
154 def _apply_query(cursor, query: PgQuery) -> None:
155 try:
156 cursor.execute(query.body)
157 except psycopg2.DatabaseError as exc:
158 error_message = (
159 f'PostgreSQL apply query error\nQuery from: {query.source}\n'
160 )
161 if query.path:
162 error_message += f'File path: {query.path}\n'
163 error_message += '\n' + str(exc)
164 raise exceptions.PostgresqlError(error_message) from None
165
166
168 def __init__(self, connection: ConnectionWrapper):
169 self._connection = connection
170
171 @property
172 def conninfo(self) -> connection.PgConnectionInfo:
173 """returns
174 :py:class:`testsuite.databases.pgsql.connection.PgConnectionInfo`
175 """
176 return self._connection.conninfo
177
178 @property
179 def conn(self) -> psycopg2.extensions.connection:
180 """:returns: :py:class:`psycopg2.extensions.connection`"""
181 return self._connection.conn
182
183 def cursor(self, **kwargs) -> psycopg2.extensions.cursor:
184 """:returns: :py:class:`psycopg2.extensions.cursor`"""
185 return self._connection.cursor(**kwargs)
186
187 def dict_cursor(self, **kwargs) -> psycopg2.extensions.cursor:
188 """Returns dictionary cursor, see psycopg2.extras.DictCursor
189
190 :returns: :py:class:`psycopg2.extensions.cursor`
191 """
192 return self._connection.dict_cursor(**kwargs)
193
194 def apply_queries(self, queries: typing.Iterable[str]) -> None:
195 """Apply queries to database"""
196 warnings.warn(
197 'Do not use apply_queries directly, use @pytest.mark.pgsql instead',
198 )
200 [
201 PgQuery(
202 body=query,
203 source='explicitly used apply_queries',
204 path=None,
205 )
206 for query in queries
207 ],
208 )
209
210
212 _applied_schemas: dict[str, set[pathlib.Path]]
213 _connections: dict[str, ConnectionWrapper]
214 _connection_pool: pool.AutocommitConnectionPool | None
215 _applied_schema_hashes: testsuite_db.AppliedSchemaHashes | None
216
217 def __init__(
218 self,
219 pgsql_conninfo: connection.PgConnectionInfo,
220 *,
221 verbose: int,
222 skip_applied_schemas: bool,
223 ) -> None:
224 self._connection_pool = None
225 self._conninfo = pgsql_conninfo
226 self._connections = {}
227 self._psql_helper = _get_psql_helper()
228 self._pgmigrate = _get_pgmigrate()
229 self._verbose = verbose
230 self._applied_schemas = {}
231 self._skip_applied_schemas = skip_applied_schemas
232 self._applied_schema_hashes = None
233
234 def initialize(self) -> None:
235 if not self._connection_pool:
237 minconn=1, maxconn=10, uri=self._get_connection_uri('postgres')
238 )
239
240 if self._skip_applied_schemas:
242 self._connection_pool,
243 self._conninfo,
244 )
245
246 def get_connection_cached(self, dbname) -> ConnectionWrapper:
247 if dbname not in self._connections:
248 self._connections[dbname] = ConnectionWrapper(
249 self._conninfo.replace(dbname=dbname),
250 )
251 return self._connections[dbname]
252
253 def initialize_sharded_db(
254 self,
255 database: discover.PgShardedDatabase,
256 ) -> None:
257 logger.debug(
258 'Initializing database %s for service %s...',
259 database.dbname,
260 database.service_name,
261 )
262 for shard in database.shards:
263 self._initialize_shard(shard)
264
265 def _initialize_shard(self, shard: discover.PgShard) -> None:
266 logger.debug('Initializing shard %s', shard.dbname)
267 if self._applied_schema_hashes is None:
268 self._create_database(shard.dbname)
269 self._apply_schema(shard)
270 else:
271 applied_hash = self._applied_schema_hashes.get_hash(shard.dbname)
272 current_hash = shard.get_schema_hash()
273 if applied_hash is not None and current_hash == applied_hash:
274 logger.debug('Shard %s: schema is up to date', shard.dbname)
275 else:
276 self._create_database(shard.dbname)
277 self._apply_schema(shard)
278 self._applied_schema_hashes.set_hash(
279 shard.dbname,
280 current_hash,
281 )
282
283 def _create_database(self, dbname: str) -> None:
284 if dbname in self._applied_schemas:
285 return
286
287 logger.debug('Creating database %s', dbname)
288 with self._connection_pool.get_connection() as connection:
289 with connection.cursor() as cursor:
290 cursor.execute(DROP_DATABASE_TEMPLATE.format(dbname))
291 cursor.execute(CREATE_DATABASE_TEMPLATE.format(dbname))
292 self._applied_schemas[dbname] = set()
293
294 def _apply_schema(self, shard: discover.PgShard) -> None:
295 applied_schemas = self._applied_schemas[shard.dbname]
296 for path in shard.files:
297 if path in applied_schemas:
298 continue
299 self._run_script(shard.dbname, path)
300 applied_schemas.add(path)
301
302 if shard.migrations:
303 for path in shard.migrations:
304 if path in applied_schemas:
305 continue
306 self._run_pgmigrate(shard.dbname, path)
307 applied_schemas.add(path)
308
309 def _run_script(self, dbname, path) -> None:
310 logger.debug(
311 'Running sql script %s against database %s',
312 path,
313 dbname,
314 )
315 command = [
316 str(self._psql_helper),
317 '-q',
318 '-d',
319 self._get_connection_uri(dbname),
320 '-v',
321 'ON_ERROR_STOP=1',
322 '-f',
323 path,
324 ]
325 try:
326 shell.execute(
327 command,
328 verbose=self._verbose,
329 command_alias='psql',
330 )
331 except shell.SubprocessFailed as exc:
333 f'Failed to run psql script for DB {dbname!r}, see logs\n'
334 f'path: {path}\n\n'
335 f'{exc}',
336 ) from None
337
338 def _run_pgmigrate(self, dbname, path) -> None:
339 logger.debug(
340 'Running migrations from %s against database %s',
341 path,
342 dbname,
343 )
344 command = [
345 str(self._pgmigrate),
346 '-c',
347 self._get_connection_dsn(dbname),
348 '-d',
349 str(path),
350 '-t',
351 'latest',
352 '-vv',
353 'migrate',
354 ]
355 try:
356 shell.execute(
357 command,
358 verbose=self._verbose,
359 command_alias='pgmigrate',
360 )
361 except shell.SubprocessFailed as exc:
363 f'Failed to run pgmigrate for DB {dbname!r}, see logs\n'
364 f'path: {path}\n\n'
365 f'{exc}'
366 ) from None
367
368 def close(self):
369 if self._connection_pool:
370 self._connection_pool.close()
371 for conn in self._connections.values():
372 conn.close()
373
374 def _get_connection_uri(self, dbname: str) -> str:
375 return self._conninfo.replace(dbname=dbname).get_uri()
376
377 def _get_connection_dsn(self, dbname: str) -> str:
378 return self._conninfo.replace(dbname=dbname).get_dsn()
379
380
381def _get_psql_helper() -> pathlib.Path:
382 return service.SCRIPTS_DIR.joinpath('psql-helper')
383
384
385def _get_pgmigrate() -> pathlib.Path:
386 return service.SCRIPTS_DIR.joinpath('pgmigrate-helper')