v0.166.0
  1"""
  2What a test run may drop that it did not make, and how that is decided.
  3
  4A run that was killed leaves its databases behind. Something has to remove
  5them, and whatever does is dropping databases it didn't create, which can't
  6be undone. So the rule is narrow, and it is decided in one place:
  7`judge_leftover`, a function of facts that touches no database.
  8
  9**The record.** A run writes a record into the comment of each database it
 10creates: the database it was testing, its own name, where it was started,
 11and by which process. A database with no record is never dropped by this
 12module, whatever it is named. A name proves nothing: `test_shop` is the
 13start of `test_shop_api`'s databases too, and anyone can create a database
 14called `test_something`.
 15
 16**Whose it is.** A database is considered only for the configured database
 17its record names, compared as a whole value.
 18
 19**What proves its run is dead.** All of these, together:
 20
 211. The record says the run marks itself alive the way this code does (the
 22   advisory lock in `run_lock_key`). A record from code that does it
 23   another way proves nothing here.
 242. Nobody holds that lock. It is asked for, and held while the database is
 25   dropped, so a run can't take the name in the middle.
 263. The process that made it isn't running, when it was on this machine.
 274. Nothing is connected to the database.
 28
 29And it is dropped without `FORCE`, which fails if something connected in
 30the meantime. A failure there means something is using it, and it is left.
 31
 32A run's lock goes with its connection to the maintenance database, and a
 33connection can be lost while the run lives on. That is why the lock alone
 34isn't the proof.
 35
 36**The template.** A run's database is cloned from a template that an
 37earlier run built and left for the rest (`database.py`). A template carries
 38a record of its own: which database it is for, the digest of the schema it
 39was built from, and whether it is finished. The rule for one is
 40`judge_template`, and it is as narrow: a template is dropped only on its
 41own record, and only when it is of no use now, which is when the schema has
 42changed since it was built, when the database it was built for is gone, or
 43when the run building it died before it was finished. While a run is
 44building or cloning it, the run holds its lock, and it is left.
 45"""
 46
 47import hashlib
 48import json
 49import os
 50import socket
 51from collections.abc import Callable, Generator
 52from contextlib import contextmanager
 53from dataclasses import asdict, dataclass
 54
 55import psycopg
 56from plain.postgres.database_url import DatabaseConfig
 57from plain.postgres.databases import (
 58    connection_count,
 59    drop_database,
 60    get_database_comment,
 61)
 62from plain.postgres.sources import build_connection_params
 63from psycopg import errors
 64
 65# The key in a database's comment that holds a run's record.
 66RECORD_KEY = "plain_test_run"
 67
 68# How a run that wrote a record marks itself alive. Code that changes the
 69# lock, or what it is keyed by, changes this, and then neither version takes
 70# the other's databases for a dead run's.
 71LOCK_SCHEME = "advisory-lock-on-run-name-1"
 72
 73
 74@dataclass(frozen=True)
 75class RunRecord:
 76    """What a run writes into the comment of a database it creates."""
 77
 78    database: str  # the configured database the run was testing
 79    run: str  # the run's shared database's name, which its lock is keyed by
 80    token: str  # this database's alone, so two of one name are told apart
 81    directory: str  # where the run was started
 82    host: str
 83    pid: int
 84    lock: str = LOCK_SCHEME
 85
 86    def as_comment(self) -> str:
 87        return json.dumps({RECORD_KEY: asdict(self)})
 88
 89
 90def read_run_record(comment: str | None) -> RunRecord | None:
 91    """The record in a database's comment, or `None` when it holds none.
 92
 93    A record that is missing a field, or has one of the wrong type, is no
 94    record: a database is dropped on what its record says, so one that
 95    can't be read in full says nothing.
 96    """
 97    if not comment:
 98        return None
 99    try:
100        decoded = json.loads(comment)
101    except ValueError:
102        return None
103    if not isinstance(decoded, dict):
104        return None
105    fields = decoded.get(RECORD_KEY)
106    if not isinstance(fields, dict):
107        return None
108
109    text_fields = ("database", "run", "token", "directory", "host", "lock")
110    if set(fields) != {*text_fields, "pid"}:
111        return None
112    if not all(isinstance(fields[name], str) and fields[name] for name in text_fields):
113        return None
114    if type(fields["pid"]) is not int:
115        return None
116    return RunRecord(**fields)
117
118
119def run_lock_key(run_name: str) -> int:
120    """The advisory lock a run holds, from the name of its shared database.
121    A template's lock is keyed the same way, by the template's name."""
122    digest = hashlib.sha256(f"plain.postgres.testing:{run_name}".encode()).digest()
123    return int.from_bytes(digest[:8], "big", signed=True)
124
125
126# The key in a database's comment that holds a template's record.
127TEMPLATE_RECORD_KEY = "plain_test_template"
128
129# A template's state: being built, by a run that holds its lock and may die
130# before it is done; or ready, migrated and converged, for runs to clone.
131TEMPLATE_BUILDING = "building"
132TEMPLATE_READY = "ready"
133
134
135@dataclass(frozen=True)
136class TemplateRecord:
137    """What a run writes into the comment of the template it builds."""
138
139    database: str  # the configured database the template is for
140    schema: str  # the digest of what it was built from (`schema.py`)
141    state: str  # TEMPLATE_BUILDING, then TEMPLATE_READY
142    directory: str  # where the run that built it was started
143    host: str
144    pid: int
145    lock: str = LOCK_SCHEME
146
147    def as_comment(self) -> str:
148        return json.dumps({TEMPLATE_RECORD_KEY: asdict(self)})
149
150
151def read_template_record(comment: str | None) -> TemplateRecord | None:
152    """The template record in a database's comment, or `None` when it holds
153    none. As with a run's record, one that can't be read in full is none."""
154    if not comment:
155        return None
156    try:
157        decoded = json.loads(comment)
158    except ValueError:
159        return None
160    if not isinstance(decoded, dict):
161        return None
162    fields = decoded.get(TEMPLATE_RECORD_KEY)
163    if not isinstance(fields, dict):
164        return None
165
166    text_fields = ("database", "schema", "state", "directory", "host", "lock")
167    if set(fields) != {*text_fields, "pid"}:
168        return None
169    if not all(isinstance(fields[name], str) and fields[name] for name in text_fields):
170        return None
171    if fields["state"] not in (TEMPLATE_BUILDING, TEMPLATE_READY):
172        return None
173    if type(fields["pid"]) is not int:
174        return None
175    return TemplateRecord(**fields)
176
177
178@dataclass(frozen=True)
179class LeftoverFacts:
180    """Everything `judge_leftover` goes by."""
181
182    name: str
183    record: RunRecord | None
184    # The run's lock was asked for and got. False when someone holds it, and
185    # when it wasn't asked for.
186    lock_is_free: bool
187    connections: int
188    # Whether the process that made it is running. `None` when it was made
189    # on another machine, where that can't be known.
190    process_is_running: bool | None
191
192
193@dataclass(frozen=True)
194class LeftoverVerdict:
195    drop: bool
196    reason: str
197    # Left, and not because its run is known to be alive or it is someone
198    # else's: worth a line of output.
199    in_doubt: bool = False
200
201
202def judge_leftover(facts: LeftoverFacts, *, tested_database: str) -> LeftoverVerdict:
203    """Whether `facts.name` is what a dead run of `tested_database` left."""
204    record = facts.record
205    if record is None:
206        return LeftoverVerdict(
207            drop=False, reason="it carries no record that a test run made it"
208        )
209    if record.database != tested_database:
210        return LeftoverVerdict(
211            drop=False, reason=f"it is a run's of {record.database!r}"
212        )
213    if record.lock != LOCK_SCHEME:
214        return LeftoverVerdict(
215            drop=False,
216            reason=(
217                "the run that made it marks itself alive another way"
218                f" ({record.lock!r}), so whether it is alive can't be told"
219            ),
220            in_doubt=True,
221        )
222    if not facts.lock_is_free:
223        return LeftoverVerdict(drop=False, reason="a run in progress holds it")
224    if facts.process_is_running:
225        return LeftoverVerdict(
226            drop=False,
227            reason=(
228                f"process {record.pid}, which made it, is running,"
229                " though it holds no lock"
230            ),
231            in_doubt=True,
232        )
233    if facts.connections:
234        return LeftoverVerdict(
235            drop=False,
236            reason=(
237                f"{_connections(facts.connections)} open to it, though no run holds it"
238            ),
239            in_doubt=True,
240        )
241    return LeftoverVerdict(
242        drop=True,
243        reason=(
244            f"a test run left it (process {record.pid}, started in"
245            f" {record.directory}) and is no longer alive"
246        ),
247    )
248
249
250def _connections(count: int) -> str:
251    return "1 connection is" if count == 1 else f"{count} connections are"
252
253
254@dataclass(frozen=True)
255class TemplateFacts:
256    """Everything `judge_template` goes by."""
257
258    name: str
259    record: TemplateRecord | None
260    # The template's lock was asked for and got. False when a run holds it,
261    # building the template or cloning from it, and when it wasn't asked for.
262    lock_is_free: bool
263    connections: int
264    # Whether the process that was building it is running. `None` when it
265    # was on another machine, where that can't be known.
266    process_is_running: bool | None
267
268
269def judge_template(
270    facts: TemplateFacts,
271    *,
272    current_schema: str | None,
273    tested_database_is_gone: bool = False,
274) -> LeftoverVerdict:
275    """
276    Whether `facts.name` is a template of no use now.
277
278    `current_schema` is the digest of the schema the database it is for
279    would be built with now, or `None` when that isn't known here (another
280    checkout's database: its own runs know). `tested_database_is_gone` says
281    the database it was built for is no longer on the server.
282    """
283    record = facts.record
284    if record is None:
285        return LeftoverVerdict(
286            drop=False, reason="it carries no record that a test run made it"
287        )
288    if record.lock != LOCK_SCHEME:
289        return LeftoverVerdict(
290            drop=False,
291            reason=(
292                "the run that built it marks itself alive another way"
293                f" ({record.lock!r}), so whether it is in use can't be told"
294            ),
295            in_doubt=True,
296        )
297    if not facts.lock_is_free:
298        return LeftoverVerdict(
299            drop=False, reason="a run in progress is building it or cloning from it"
300        )
301    if record.state == TEMPLATE_BUILDING:
302        if facts.process_is_running:
303            return LeftoverVerdict(
304                drop=False,
305                reason=(
306                    f"process {record.pid}, which was building it, is running,"
307                    " though it holds no lock"
308                ),
309                in_doubt=True,
310            )
311        if facts.connections:
312            return LeftoverVerdict(
313                drop=False,
314                reason=(
315                    f"{_connections(facts.connections)} open to it, though the run"
316                    " that was building it holds no lock"
317                ),
318                in_doubt=True,
319            )
320        return LeftoverVerdict(
321            drop=True,
322            reason=(
323                f"the run that was building it (process {record.pid}, started in"
324                f" {record.directory}) is gone, and it was never finished"
325            ),
326        )
327    if tested_database_is_gone:
328        if facts.connections:
329            return LeftoverVerdict(
330                drop=False,
331                reason=(
332                    f"{_connections(facts.connections)} open to it, though the"
333                    f" database it was built for, {record.database!r}, is gone"
334                ),
335                in_doubt=True,
336            )
337        return LeftoverVerdict(
338            drop=True,
339            reason=f"the database it was built for, {record.database!r}, is gone",
340        )
341    if current_schema is None:
342        return LeftoverVerdict(
343            drop=False,
344            reason=(
345                f"the template {record.database!r}'s test runs clone; a run of"
346                " that database replaces it when the schema changes"
347            ),
348        )
349    if record.schema == current_schema:
350        return LeftoverVerdict(
351            drop=False, reason="the template this checkout's test runs clone"
352        )
353    if facts.connections:
354        return LeftoverVerdict(
355            drop=False,
356            reason=(
357                f"{_connections(facts.connections)} open to it, though the schema"
358                " has changed since it was built"
359            ),
360            in_doubt=True,
361        )
362    return LeftoverVerdict(
363        drop=True,
364        reason="the schema changed since it was built, and a run built a new one",
365    )
366
367
368def process_is_running(record: RunRecord | TemplateRecord) -> bool | None:
369    """Whether the process that wrote `record` is running on this machine."""
370    if record.host != socket.gethostname():
371        return None
372    if record.pid == os.getpid():
373        # This process. What carries its id and isn't held by it was made
374        # by an earlier process that had the same one.
375        return False
376    try:
377        os.kill(record.pid, 0)
378    except ProcessLookupError:
379        return False
380    except PermissionError:
381        return True  # someone else's process, and running
382    return True
383
384
385@contextmanager
386def maintenance_connection(config: DatabaseConfig) -> Generator[psycopg.Connection]:
387    """A connection to the `postgres` maintenance database, where a run's
388    lock is asked for."""
389    maintenance_config: DatabaseConfig = {**config, "DATABASE": "postgres"}
390    with psycopg.connect(
391        **build_connection_params(maintenance_config), autocommit=True
392    ) as maintenance:
393        yield maintenance
394
395
396@contextmanager
397def _run_lock(
398    maintenance: psycopg.Connection, run_name: str, *, already_held: bool
399) -> Generator[bool]:
400    """Ask for a run's lock. Yields whether it is held here, and gives it
401    back afterwards unless it was held before."""
402    if already_held:
403        yield True
404        return
405
406    key = run_lock_key(run_name)
407    row = maintenance.execute("SELECT pg_try_advisory_lock(%s)", [key]).fetchone()
408    assert row is not None
409    got_it = bool(row[0])
410    try:
411        yield got_it
412    finally:
413        if got_it:
414            maintenance.execute("SELECT pg_advisory_unlock(%s)", [key])
415
416
417def look_at_leftover(
418    maintenance: psycopg.Connection,
419    config: DatabaseConfig,
420    *,
421    name: str,
422    record: RunRecord | None,
423) -> LeftoverFacts:
424    """The facts about one database, read and nothing else. The run's lock
425    is asked for and given straight back."""
426    if record is None or record.lock != LOCK_SCHEME:
427        lock_is_free = False
428    else:
429        with _run_lock(maintenance, record.run, already_held=False) as got_it:
430            lock_is_free = got_it
431    return LeftoverFacts(
432        name=name,
433        record=record,
434        lock_is_free=lock_is_free,
435        connections=connection_count(config, name=name),
436        process_is_running=None if record is None else process_is_running(record),
437    )
438
439
440def drop_if_a_dead_runs(
441    maintenance: psycopg.Connection,
442    config: DatabaseConfig,
443    *,
444    name: str,
445    record: RunRecord,
446    tested_database: str,
447    held_run: str = "",
448) -> LeftoverVerdict:
449    """Drop the database `name` if a dead run of `tested_database` left it.
450
451    `record` is what its comment held when it was listed. Everything is
452    read again here with the run's lock held, and the database is dropped
453    only if it is still the one that was listed.
454
455    `held_run` is the name of the run whose lock `maintenance` holds
456    already: a run looking at what an earlier one of its own name left.
457    """
458    if record.lock != LOCK_SCHEME:
459        facts = LeftoverFacts(
460            name=name,
461            record=record,
462            lock_is_free=False,
463            connections=0,
464            process_is_running=None,
465        )
466        return judge_leftover(facts, tested_database=tested_database)
467
468    with _run_lock(
469        maintenance, record.run, already_held=record.run == held_run
470    ) as got_it:
471        now = read_run_record(get_database_comment(config, name=name))
472        if now != record:
473            return LeftoverVerdict(
474                drop=False, reason="it isn't the database that was listed"
475            )
476
477        facts = LeftoverFacts(
478            name=name,
479            record=record,
480            lock_is_free=got_it,
481            connections=connection_count(config, name=name),
482            process_is_running=process_is_running(record),
483        )
484        verdict = judge_leftover(facts, tested_database=tested_database)
485        if not verdict.drop:
486            return verdict
487
488        try:
489            # Not forced. If something connected since it was counted, this
490            # fails, and the database is somebody's.
491            drop_database(config, name=name, force=False)
492        except errors.ObjectInUse:
493            return LeftoverVerdict(
494                drop=False,
495                reason="something connected to it as it was being dropped",
496                in_doubt=True,
497            )
498        return verdict
499
500
501def look_at_template(
502    maintenance: psycopg.Connection,
503    config: DatabaseConfig,
504    *,
505    name: str,
506    record: TemplateRecord | None,
507) -> TemplateFacts:
508    """The facts about one template, read and nothing else. Its lock is
509    asked for and given straight back."""
510    if record is None or record.lock != LOCK_SCHEME:
511        lock_is_free = False
512    else:
513        with _run_lock(maintenance, name, already_held=False) as got_it:
514            lock_is_free = got_it
515    return TemplateFacts(
516        name=name,
517        record=record,
518        lock_is_free=lock_is_free,
519        connections=connection_count(config, name=name),
520        process_is_running=None if record is None else process_is_running(record),
521    )
522
523
524def drop_if_a_stale_template(
525    maintenance: psycopg.Connection,
526    config: DatabaseConfig,
527    *,
528    name: str,
529    record: TemplateRecord,
530    current_schema: str | None,
531    tested_database_is_gone: bool = False,
532) -> LeftoverVerdict:
533    """Drop the template `name` if it is of no use now (`judge_template`).
534
535    `record` is what its comment held when it was listed. Everything is
536    read again here with the template's lock held, so no run starts
537    cloning it in the middle, and it is dropped only if it is still the one
538    that was listed.
539    """
540    if record.lock != LOCK_SCHEME:
541        facts = TemplateFacts(
542            name=name,
543            record=record,
544            lock_is_free=False,
545            connections=0,
546            process_is_running=None,
547        )
548        return judge_template(
549            facts,
550            current_schema=current_schema,
551            tested_database_is_gone=tested_database_is_gone,
552        )
553
554    with _run_lock(maintenance, name, already_held=False) as got_it:
555        now = read_template_record(get_database_comment(config, name=name))
556        if now != record:
557            return LeftoverVerdict(
558                drop=False, reason="it isn't the database that was listed"
559            )
560
561        facts = TemplateFacts(
562            name=name,
563            record=record,
564            lock_is_free=got_it,
565            connections=connection_count(config, name=name),
566            process_is_running=process_is_running(record),
567        )
568        verdict = judge_template(
569            facts,
570            current_schema=current_schema,
571            tested_database_is_gone=tested_database_is_gone,
572        )
573        if not verdict.drop:
574            return verdict
575
576        try:
577            # Not forced, as for a run's database.
578            drop_database(config, name=name, force=False)
579        except errors.ObjectInUse:
580            return LeftoverVerdict(
581                drop=False,
582                reason="something connected to it as it was being dropped",
583                in_doubt=True,
584            )
585        return verdict
586
587
588def remove_what_dead_runs_left(
589    maintenance: psycopg.Connection,
590    config: DatabaseConfig,
591    *,
592    tested_database: str,
593    held_run: str,
594    current_schema: str,
595    say: Callable[[str], None],
596) -> list[str]:
597    """
598    Drop the databases that dead runs of `tested_database` left, and the
599    templates of `tested_database` that are of no use now. The names of
600    those dropped.
601
602    Only a database whose record names `tested_database` is looked at. One
603    that is left in doubt gets a line through `say`.
604    """
605    rows = maintenance.execute(
606        "SELECT datname, shobj_description(oid, 'pg_database') "
607        "FROM pg_database WHERE NOT datistemplate ORDER BY datname"
608    ).fetchall()
609
610    dropped = []
611    for name, comment in rows:
612        run_record = read_run_record(comment)
613        template_record = read_template_record(comment)
614        if run_record is not None and run_record.database == tested_database:
615            verdict = drop_if_a_dead_runs(
616                maintenance,
617                config,
618                name=name,
619                record=run_record,
620                tested_database=tested_database,
621                held_run=held_run,
622            )
623        elif (
624            template_record is not None and template_record.database == tested_database
625        ):
626            verdict = drop_if_a_stale_template(
627                maintenance,
628                config,
629                name=name,
630                record=template_record,
631                current_schema=current_schema,
632            )
633        else:
634            continue
635        if verdict.drop:
636            dropped.append(name)
637        elif verdict.in_doubt:
638            say(
639                f"Left the test database {name!r}: {verdict.reason}."
640                f" If it is debris: plain db drop {name}"
641            )
642    return dropped