1"""
2The databases a test run uses, and how the run is pointed at them.
3
4A run has databases of its own, named for the checkout's database and for
5the run:
6
7 test_<database>_r<pid> the rolled-back tests share it
8 test_<database>_r<pid>_<test name> one `@isolated_db` test's
9
10So any number of runs in one checkout, at the same moment, each create,
11use and drop their own. A run that ends drops its databases. One that was
12killed leaves them, and the next run of that database removes them.
13
14**The template.** A run's databases are clones of one more, which stays:
15
16 test_<database>_t<schema> migrated and converged once
17
18It is built by the first run that needs it, from nothing (create, migrate,
19converge), and every run after clones it with `CREATE DATABASE ... TEMPLATE`,
20which copies the files and takes a moment whatever the schema. It is named
21by a digest of what it was built from (`schema.py`), so a changed migration
22or model is a new template, and the old one is removed by the next run's
23sweep. One is kept per checkout's database.
24
25**What a run may drop.** Its own databases, which it made. And what a dead
26run left, which it didn't, so that is decided narrowly: only a database
27that carries a run's record, for this configured database exactly, whose
28run is proved dead; or a template's record, for this database exactly, that
29is of no use now. `leftovers.py` has the rules. A database is never looked
30for by the start of its name.
31
32**Which runs are alive.** For as long as it lives, a run holds a session
33advisory lock on the `postgres` maintenance database, keyed by the name of
34its shared database. Postgres releases the lock when the run's connection
35goes, however the run ended. A run building or cloning the template holds
36the template's lock the same way, for as long as that takes.
37
38**Where a connection goes.** While a test database is in use,
39`POSTGRES_URL` *is* that database: the setting is changed, and the pool is
40rebuilt on it. The test gets a connection of its own through the
41connection `ContextVar`, as before. Anything else that asks for a
42connection the ordinary way (a thread the code under test started has an
43empty context, so `get_connection()` makes it a new one) gets one from
44the pool, to the test database. Nothing in the run can reach the
45development database by asking. `POSTGRES_MANAGEMENT_URL`, if set, is
46pointed at the test database too.
47"""
48
49import os
50import re
51import secrets
52import socket
53import sys
54import time
55from collections.abc import Generator
56from contextlib import contextmanager
57from dataclasses import replace
58
59import psycopg
60from plain.postgres.connection import DatabaseConnection
61from plain.postgres.database_url import (
62 DatabaseConfig,
63 parse_database_url,
64 replace_database_name,
65)
66from plain.postgres.databases import (
67 create_database,
68 database_exists,
69 drop_database,
70 get_database_comment,
71 set_database_comment,
72 terminate_connections,
73)
74from plain.postgres.db import _db_conn
75from plain.postgres.dialect import MAX_NAME_LENGTH
76from plain.postgres.migrations.executor import MigrationExecutor
77from plain.postgres.sources import (
78 DirectSource,
79 build_connection_params,
80 runtime_pool_source,
81)
82from plain.postgres.utils import names_digest
83from plain.runtime import settings
84from psycopg import errors
85
86from .leftovers import (
87 TEMPLATE_BUILDING,
88 TEMPLATE_READY,
89 RunRecord,
90 TemplateRecord,
91 read_run_record,
92 read_template_record,
93 remove_what_dead_runs_left,
94 run_lock_key,
95)
96from .schema import SchemaDigest, read_server_version
97
98TEST_DATABASE_PREFIX = "test_"
99
100# The digest that ends a name that had to be cut to fit, and the `_` before it.
101_DIGEST_LENGTH = 8
102_CUT_ROOM = _DIGEST_LENGTH + 1
103
104# The shared database's name leaves room for an isolated test's name to
105# follow it: at least `_` and a digest.
106_SHARED_NAME_LIMIT = MAX_NAME_LENGTH - _CUT_ROOM
107
108# The room kept for a run token is the longest one's, so that where a
109# database's name is cut doesn't depend on the run, and every run of a
110# checkout starts its names the same way. A run token is `r` and the
111# process id. When another live run already holds that name (the same
112# process id on another machine, on one server), `x` and four random hex
113# digits follow. A template's token, `t` and eight hex digits of its
114# schema's digest, fits in the same room.
115_LONGEST_RUN_TOKEN = len("r4194304x0000")
116_SCHEMA_IN_NAME = 8
117
118
119def _log(msg: str) -> None:
120 sys.stderr.write(msg + os.linesep)
121
122
123def _cut_to(name: str, *, limit: int) -> str:
124 """`name` if it fits in `limit`, or its head and a digest of all of it."""
125 if len(name) <= limit:
126 return name
127 digest = names_digest(name, length=_DIGEST_LENGTH)
128 return f"{name[: limit - _CUT_ROOM]}_{digest}"
129
130
131def legacy_database_name(base_name: str) -> str:
132 """`test_<database>`: what every run of a checkout starts its names with,
133 and the whole name a run used before runs had names of their own."""
134 room_for_base = (
135 _SHARED_NAME_LIMIT - len(TEST_DATABASE_PREFIX) - _LONGEST_RUN_TOKEN - 1
136 )
137 return TEST_DATABASE_PREFIX + _cut_to(base_name, limit=room_for_base)
138
139
140def shared_database_name(base_name: str, *, run_token: str) -> str:
141 """`test_<database>_<run token>`, with the database's name cut if it has to be."""
142 return f"{legacy_database_name(base_name)}_{run_token}"
143
144
145def template_database_name(base_name: str, *, schema: str) -> str:
146 """`test_<database>_t<schema>`: the template every run of the database
147 clones, named by the first eight hex digits of its schema's digest."""
148 return shared_database_name(base_name, run_token=f"t{schema[:_SCHEMA_IN_NAME]}")
149
150
151def isolated_database_name(shared_name: str, *, test_name: str) -> str:
152 """The shared database's name, then the test's without its `test_`:
153 there is little room, and every test's name starts that way."""
154 test = re.sub(r"[^0-9A-Za-z_]+", "_", test_name).removeprefix("test_")
155 return _cut_to(f"{shared_name}_{test}", limit=MAX_NAME_LENGTH)
156
157
158class RunDatabases:
159 """
160 What a run holds for as long as it lives: a connection to the
161 maintenance database, and on it the lock that says its databases are
162 in use.
163 """
164
165 def __init__(self, *, runtime_url: str, management_url: str) -> None:
166 if not runtime_url:
167 raise ValueError(
168 "POSTGRES_URL must be set before creating a test database."
169 )
170 self.runtime_url = runtime_url
171 self.management_url = management_url
172 self.config = parse_database_url(runtime_url)
173
174 self.base_name = self.config.get("DATABASE") or ""
175 if not self.base_name:
176 raise ValueError("POSTGRES_URL must include a database name")
177
178 self.shared_name = ""
179 self.schema: SchemaDigest | None = None
180 self.template_name = ""
181 # What was done about the template, for the run's report: ("built
182 # template (49 migrations)", seconds), ("cloned template", seconds).
183 self.setup_parts: list[tuple[str, float]] = []
184 self._maintenance: psycopg.Connection | None = None
185
186 def server_version(self) -> str:
187 """The version of the server the run's databases are on, which is
188 part of what they are built from (`describe_schema`)."""
189 return read_server_version(self._maintenance_connection())
190
191 def claim(self, *, schema: SchemaDigest) -> None:
192 """Take a name for this run, and remove what dead runs left here.
193
194 `schema` is the digest of what the run's databases are built from
195 (`describe_schema()`), which names the template they are cloned
196 from.
197 """
198 self._maintenance = self._maintenance_connection()
199 self.schema = schema
200 self.template_name = template_database_name(self.base_name, schema=schema.hash)
201
202 run_token = f"r{os.getpid()}"
203 while True:
204 shared_name = shared_database_name(self.base_name, run_token=run_token)
205 if self._try_lock(shared_name):
206 break
207 run_token = f"r{os.getpid()}x{secrets.token_hex(2)}"
208 self.shared_name = shared_name
209
210 remove_what_dead_runs_left(
211 self._maintenance,
212 self.config,
213 tested_database=self.base_name,
214 held_run=self.shared_name,
215 current_schema=schema.hash,
216 say=_log,
217 )
218
219 def release(self) -> None:
220 """Give the name up. Closing the connection releases the lock."""
221 if self._maintenance is not None:
222 self._maintenance.close()
223 self._maintenance = None
224
225 def isolated_name(self, test_name: str) -> str:
226 return isolated_database_name(self.shared_name, test_name=test_name)
227
228 def record_for_a_database(self) -> RunRecord:
229 """What this run writes into a database it creates: that a test run
230 made it, for which configured database, and which run."""
231 return RunRecord(
232 database=self.base_name,
233 run=self.shared_name,
234 token=secrets.token_hex(8),
235 directory=os.getcwd(),
236 host=socket.gethostname(),
237 pid=os.getpid(),
238 )
239
240 def record_for_the_template(self) -> TemplateRecord:
241 """What this run writes into the template it builds."""
242 assert self.schema is not None
243 return TemplateRecord(
244 database=self.base_name,
245 schema=self.schema.hash,
246 state=TEMPLATE_BUILDING,
247 directory=os.getcwd(),
248 host=socket.gethostname(),
249 pid=os.getpid(),
250 )
251
252 def create_from_template(self, *, name: str) -> None:
253 """Create the database `name` as a clone of the template, building
254 the template first if no run has.
255
256 The template's lock is held throughout, so two runs never build it
257 at once, and nothing drops it while a run is cloning it. A run that
258 finds the lock held waits: the run holding it is building the
259 template, or cloning it, which takes a moment.
260 """
261 assert self._maintenance is not None
262 key = run_lock_key(self.template_name)
263 self._maintenance.execute("SELECT pg_advisory_lock(%s)", [key])
264 try:
265 self._ensure_template()
266 started = time.perf_counter()
267 _create_test_database(
268 self.config,
269 name=name,
270 made_by=self.record_for_a_database(),
271 template=self.template_name,
272 )
273 self.setup_parts.append(("cloned template", time.perf_counter() - started))
274 finally:
275 self._maintenance.execute("SELECT pg_advisory_unlock(%s)", [key])
276
277 def _ensure_template(self) -> None:
278 """Build the template if it isn't there, ready, for this schema.
279
280 Called with the template's lock held. A database of the template's
281 name that carries a template record for this configured database
282 is a template of this run's own kind: one a run left half built,
283 or one for a schema whose digest starts the same way. It is dropped
284 and built again. Any other database of the name is somebody's, and
285 is left: the run stops.
286 """
287 assert self.schema is not None
288 name = self.template_name
289 found = read_template_record(get_database_comment(self.config, name=name))
290 if (
291 found is not None
292 and found.database == self.base_name
293 and found.schema == self.schema.hash
294 and found.state == TEMPLATE_READY
295 ):
296 return
297
298 if database_exists(self.config, name=name):
299 if found is None or found.database != self.base_name:
300 raise RuntimeError(
301 f"A database named {name!r} is already there, and no test run"
302 " of this database built it, so it is left as it is. This run"
303 f" needs the name. If the database is debris: plain db drop {name}"
304 )
305 # Not forced: nothing should be connected to a template that no
306 # run holds the lock on, and if something is, it is in use.
307 drop_database(self.config, name=name, force=False)
308
309 started = time.perf_counter()
310 record = self.record_for_the_template()
311 create_database(self.config, name=name)
312 set_database_comment(self.config, name=name, comment=record.as_comment())
313 with _pointed_at(
314 name, runtime_url=self.runtime_url, management_url=self.management_url
315 ) as connection:
316 _migrate_and_converge(connection)
317 set_database_comment(
318 self.config,
319 name=name,
320 comment=replace(record, state=TEMPLATE_READY).as_comment(),
321 )
322 self.setup_parts.append(
323 (
324 f"built template ({_migrations(self.schema.migrations)})",
325 time.perf_counter() - started,
326 )
327 )
328
329 def _maintenance_connection(self) -> psycopg.Connection:
330 """The run's connection to the maintenance database, opened the
331 first time it is asked for and held until `release()`."""
332 if self._maintenance is None:
333 maintenance_config: DatabaseConfig = {**self.config, "DATABASE": "postgres"}
334 self._maintenance = psycopg.connect(
335 **build_connection_params(maintenance_config), autocommit=True
336 )
337 return self._maintenance
338
339 def _try_lock(self, shared_name: str) -> bool:
340 assert self._maintenance is not None
341 row = self._maintenance.execute(
342 "SELECT pg_try_advisory_lock(%s)", [run_lock_key(shared_name)]
343 ).fetchone()
344 assert row is not None
345 return row[0]
346
347
348def _migrations(count: int) -> str:
349 return "1 migration" if count == 1 else f"{count} migrations"
350
351
352def _create_test_database(
353 config: DatabaseConfig, *, name: str, made_by: RunRecord, template: str
354) -> None:
355 """Create the database `name` as a clone of `template`, and write into
356 it that this run made it.
357
358 A database already there under the name is replaced only if its record
359 says a run of this one's name made it. This run holds that name's lock,
360 so that run is this one, or an earlier one with the same process id
361 that is gone. Any other database of the name is somebody's, and is
362 left: the run stops.
363 """
364 try:
365 _clone(config, name=name, template=template)
366 except errors.DuplicateDatabase:
367 found = read_run_record(get_database_comment(config, name=name))
368 if (
369 found is None
370 or found.database != made_by.database
371 or found.run != made_by.run
372 ):
373 raise RuntimeError(
374 f"A database named {name!r} is already there, and no test run"
375 " of this name made it, so it is left as it is. This run"
376 f" needs the name. If the database is debris: plain db drop {name}"
377 ) from None
378 # Forced: it is this run's name, and this run holds the name's lock.
379 drop_database(config, name=name, force=True)
380 _clone(config, name=name, template=template)
381
382 set_database_comment(config, name=name, comment=made_by.as_comment())
383
384
385def _clone(config: DatabaseConfig, *, name: str, template: str) -> None:
386 """`CREATE DATABASE name TEMPLATE template`.
387
388 Postgres refuses while anything is connected to the template. The run
389 holds the template's lock, so no other run is building or cloning it,
390 and a connection to it is one this process left open (the pool is
391 closed after the build, but a thread of the code under test may have
392 taken one): it is ended, and the clone tried once more.
393 """
394 try:
395 create_database(config, name=name, template=template)
396 except errors.ObjectInUse:
397 terminate_connections(config, name=template)
398 create_database(config, name=name, template=template)
399
400
401@contextmanager
402def _pointed_at(
403 name: str, *, runtime_url: str, management_url: str
404) -> Generator[DatabaseConnection]:
405 """Make the database `name` the one in use, for the block.
406
407 Inside it `get_connection()` returns a connection to it in this
408 context, and the pool hands out connections to it in every other (see
409 the module docstring). On exit the connection and the pool are closed,
410 so nothing of this process is connected to it, and the settings are as
411 they were.
412
413 `runtime_url` and `management_url` are the URLs as configured, before
414 any test database was put in their place.
415 """
416 url = replace_database_name(runtime_url, name)
417 connection = DatabaseConnection(DirectSource(parse_database_url(url)))
418
419 url_before = settings.POSTGRES_URL
420 management_url_before = settings.POSTGRES_MANAGEMENT_URL
421
422 conn_token = _db_conn.set(connection)
423 # The pool was built on the URL as it was. Closed, it is rebuilt on
424 # the setting as it is now by whoever next asks it for a connection.
425 runtime_pool_source.close()
426 settings.POSTGRES_URL = url
427 if management_url and management_url.lower() != "none":
428 settings.POSTGRES_MANAGEMENT_URL = replace_database_name(management_url, name)
429 try:
430 yield connection
431 finally:
432 _db_conn.reset(conn_token)
433
434 try:
435 connection.close()
436 except Exception:
437 pass
438
439 # The pool's connections are to this database. Closed before the
440 # settings go back, so nothing is handed one in between.
441 runtime_pool_source.close()
442 settings.POSTGRES_URL = url_before
443 settings.POSTGRES_MANAGEMENT_URL = management_url_before
444
445
446def _migrate_and_converge(connection: DatabaseConnection) -> None:
447 """Every migration, then convergence, through their Python APIs
448 (`MigrationExecutor`, `plan_convergence`), not the CLI commands."""
449 from plain.postgres.convergence import execute_plan, plan_convergence
450
451 executor = MigrationExecutor(connection)
452 targets = list(executor.loader.graph.leaf_nodes())
453 executor.migrate(targets)
454
455 plan = plan_convergence()
456 result = execute_plan(plan.executable())
457 if not result.ok:
458 failed = [r for r in result.results if not r.ok]
459 raise RuntimeError(
460 f"Convergence failed during test DB setup: {failed[0].item.describe()} — {failed[0].error}"
461 )
462 # A fresh DB from migrations shouldn't have undeclared objects or
463 # changed definitions — safety net so test setup follows sync policy.
464 if plan.blocked:
465 problem = plan.blocked[0]
466 raise RuntimeError(
467 f"Convergence blocked during test DB setup: {problem.describe()}"
468 )
469
470
471@contextmanager
472def use_test_database(
473 *, run: RunDatabases, name: str, verbosity: int = 1
474) -> Generator[str]:
475 """Create the database `name` for `run`, make it the one in use, drop
476 it on exit.
477
478 It is a clone of the run's template, so it is migrated and converged
479 already (`RunDatabases.create_from_template`). Inside the block the
480 process is pointed at it (`_pointed_at`).
481
482 Yields the test database's name.
483 """
484 if verbosity >= 1:
485 _log(f"Creating test database '{name}'...")
486
487 run.create_from_template(name=name)
488 test_config = parse_database_url(replace_database_name(run.runtime_url, name))
489
490 try:
491 with _pointed_at(
492 name, runtime_url=run.runtime_url, management_url=run.management_url
493 ) as connection:
494 connection.ensure_connection()
495 yield name
496 finally:
497 if verbosity >= 1:
498 _log(f"Destroying test database '{name}'...")
499 try:
500 # Forced, and the one place a database is: this run made it a
501 # moment ago and is done with it, and a thread the code under
502 # test started may still hold a connection.
503 drop_database(test_config, name=name, force=True)
504 except Exception as e:
505 _log(f"Got an error destroying the test database: {e}")