v0.166.0
  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}")