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