test_recovery_completion.py #1

  • //
  • guest/
  • russell_jackson/
  • sdp/
  • test/
  • test_recovery_completion.py
  • View
  • Commits
  • Open Download .zip Download (18 KB)
#!/usr/bin/env python3
"""Offline checkpoint completion/lock contracts; no live servers or transfers.

The loader tests execute its production classification, MD5 preflight and replay
blocks in a temporary environment. They do not claim whole-script/service tests.
"""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest


BIN = Path(__file__).resolve().parents[1] / "Server/Unix/p4/common/bin"
BASH = os.environ.get("SDP_TEST_BASH", shutil.which("bash"))


class RecoveryFixture(unittest.TestCase):
    def setUp(self):
        temporary = tempfile.TemporaryDirectory(prefix="sdp-recovery-completion-")
        self.addCleanup(temporary.cleanup)
        self.root = Path(temporary.name).resolve()
        self.logs = self.root / "logs"
        self.checkpoints = self.root / "checkpoints.gz-parent"
        self.database = self.root / "database"
        self.offline = self.root / "offline"
        for directory in (self.logs, self.checkpoints, self.database, self.offline):
            directory.mkdir()
        self.marker = self.logs / "ckp_running.txt"
        self.calls = self.root / "p4d-calls.jsonl"
        self.p4d = self.root / "p4d-fixture"
        self.p4d.write_text("#!" + sys.executable + r'''
import json, os, pathlib, sys
args = sys.argv[1:]
with open(os.environ["TEST_CALLS"], "a") as output:
    output.write(json.dumps(args) + "\n")
if not ("-jr" in args or "-jrp" in args):
    print("unexpected validation/capability prepass", file=sys.stderr)
    sys.exit(88)
target = pathlib.Path(args[args.index("-r") + 1])
if os.environ.get("TEST_RESTORE_FAILURE"):
    (target / "db.partial").write_text("incomplete restore")
    print("injected actual restore failure", file=sys.stderr)
    sys.exit(7)
(target / "db.counters").write_text("restored exact fixture counters")
''')
        self.p4d.chmod(0o755)
        self.env = dict(os.environ, TEST_ROOT=str(self.root), LOGS=str(self.logs),
                        LOGFILE=str(self.logs / "recovery.log"), TEST_CALLS=str(self.calls),
                        P4CBIN=str(self.root), P4DBIN=str(self.p4d),
                        P4ROOT=str(self.database), CHECKPOINTS=str(self.checkpoints),
                        P4CKP_PREFIX="p4_test", NumCheckPointThreads="6",
                        BACKUP_FUNCTIONS=str(BIN / "backup_functions.sh"))
        for name in ("CkpLockToken", "CkpLockPid", "CkpLockSubshell", "SNS_ALERT_TOPIC_ARN"):
            self.env.pop(name, None)
        self.prefix = '''source "$BACKUP_FUNCTIONS"
OFFLINE_DB="$TEST_ROOT/offline"
log () { printf '%s\\n' "$*" >> "$LOGFILE"; }
mail_log_file () { return 0; }
sdp_fqdn () { printf fixture-host; }
tty () { return 1; }
'''

    def run_shell(self, script, **extra):
        return subprocess.run([BASH, "-c", self.prefix + script], cwd=self.root,
                              env=dict(self.env, **extra), text=True,
                              capture_output=True, timeout=10)

    def recorded_calls(self):
        return [json.loads(line) for line in self.calls.read_text().splitlines()] if self.calls.exists() else []

    def checkpoint(self, kind="gzip", sidecar="normal"):
        source = self.checkpoints / "p4_test.ckp.1"
        if kind == "parallel":
            source.mkdir()
            (source / "db.config.ckp.gz").write_text("fixture checkpoint segment")
        else:
            if kind == "gzip":
                source = source.with_suffix(source.suffix + ".gz")
            source.write_text("fixture checkpoint content")
        if sidecar != "missing":
            path = Path(str(source) + ".md5")
            if sidecar == "stem":
                path = Path(str(source)[:-3] + ".md5")
            if sidecar == "directory":
                path.mkdir()
            else:
                path.write_text("Existing completion sidecar; no separate hash pass.\n")
        return source


class FailureLockRelease(RecoveryFixture):
    def test_die_releases_owned_marker_and_keeps_original_failure_status(self):
        result = self.run_shell('ckp_running; die "fixture operation failed"')
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertFalse(self.marker.exists())
        self.assertIn("fixture operation failed", (self.logs / "recovery.log").read_text())

    def test_die_preserves_unowned_legacy_marker(self):
        self.marker.write_text("other owner\n")
        result = self.run_shell('die "failure without ownership"')
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.marker.read_text(), "other owner\n")

    def test_die_preserves_replaced_marker(self):
        result = self.run_shell('''ckp_running
printf 'replacement owner\\n' > "$LOGS/ckp_running.txt"
die "operation failed after ownership changed"
''')
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.marker.read_text(), "replacement owner\n")

    def test_child_die_does_not_release_parent_marker(self):
        result = self.run_shell('''ckp_running
( die "child failure" ); status=$?
[[ "$status" == 1 && -s "$LOGS/ckp_running.txt" ]] || exit 71
ckp_complete
''')
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertFalse(self.marker.exists())

    def test_child_completion_does_not_release_parent_marker(self):
        result = self.run_shell('''ckp_running
( ckp_complete ); status=$?
[[ "$status" == 1 && -s "$LOGS/ckp_running.txt" ]] || exit 71
ckp_complete
''')
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertFalse(self.marker.exists())

    def test_failed_marker_removal_does_not_recurse_or_replace_original_error(self):
        result = self.run_shell('''ckp_running
rm () { echo 'injected marker unlink failure' >&2; return 13; }
die "original restore failure"
''')
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertTrue(self.marker.exists())
        log = (self.logs / "recovery.log").read_text()
        self.assertEqual(log.count("ERROR!!!"), 1)
        self.assertIn("original restore failure", log)
        self.assertIn("injected marker unlink failure", log + result.stderr)

    def test_missing_owned_marker_still_reports_original_failure_once(self):
        result = self.run_shell('ckp_running; rm -- "$LOGS/ckp_running.txt"; die "original failure"')
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertFalse(self.marker.exists())
        self.assertEqual((self.logs / "recovery.log").read_text().count("ERROR!!!"), 1)


class OfflineRestoreCompletion(RecoveryFixture):
    def restore(self, kind, **extra):
        return self.run_shell('''DoParallelCheckpoints=$TEST_PARALLEL
NumCheckPointThreads=6
ckp_running
recreate_offline_db_files
ckp_complete
''', TEST_PARALLEL="1" if kind == "parallel" else "0", **extra)

    def assert_single_restore(self, source, parallel=False):
        calls = self.recorded_calls()
        expected = ["-r", str(self.offline)]
        if parallel:
            expected += ["-z", "-jrp", "-N", "6", str(source)]
        else:
            if source.suffix == ".gz":
                expected += ["-z"]
            expected += ["-jr", str(source)]
        self.assertEqual(calls, [expected])

    def test_serial_restore_uses_completion_sidecar_without_verification_prepass(self):
        source = self.checkpoint()
        result = self.restore("gzip")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assert_single_restore(source)
        self.assertTrue((self.offline / "offline_db_usable.txt").is_file())

    def test_parallel_restore_does_not_require_jvp_capability(self):
        source = self.checkpoint("parallel")
        result = self.restore("parallel")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assert_single_restore(source, parallel=True)

    def test_plain_restore_keeps_existing_flags(self):
        source = self.checkpoint("plain")
        result = self.restore("plain")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assert_single_restore(source)

    def test_gzip_stem_sidecar_is_accepted(self):
        source = self.checkpoint(sidecar="stem")
        result = self.restore("gzip")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assert_single_restore(source)

    def test_missing_sidecar_preserves_existing_offline_database(self):
        self.checkpoint(sidecar="missing")
        (self.offline / "db.counters").write_text("original database")
        (self.offline / "offline_db_usable.txt").write_text("original usable marker")
        result = self.restore("gzip")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual((self.offline / "db.counters").read_text(), "original database")
        self.assertTrue((self.offline / "offline_db_usable.txt").exists())
        self.assertEqual(self.recorded_calls(), [])
        self.assertFalse(self.marker.exists())

    def test_missing_checkpoint_does_not_accept_orphan_sidecar(self):
        source = self.checkpoint()
        source.unlink()
        (self.offline / "db.counters").write_text("original database")
        result = self.restore("gzip")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual((self.offline / "db.counters").read_text(), "original database")
        self.assertEqual(self.recorded_calls(), [])

    def test_missing_parallel_sidecar_preserves_existing_offline_database(self):
        self.checkpoint("parallel", sidecar="missing")
        (self.offline / "db.counters").write_text("original database")
        result = self.restore("parallel")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual((self.offline / "db.counters").read_text(), "original database")
        self.assertEqual(self.recorded_calls(), [])

    def test_directory_named_md5_is_not_selected_as_completion_sidecar(self):
        self.checkpoint(sidecar="directory")
        (self.offline / "db.counters").write_text("original database")
        result = self.restore("gzip")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual((self.offline / "db.counters").read_text(), "original database")
        self.assertEqual(self.recorded_calls(), [])

    def test_directory_or_fifo_serial_source_does_not_remove_database(self):
        source = self.checkpoint()
        source.unlink()
        (self.offline / "db.counters").write_text("original database")
        (self.offline / "offline_db_usable.txt").write_text("original usability evidence")
        for kind in ("directory", "fifo"):
            with self.subTest(kind=kind):
                source.mkdir() if kind == "directory" else os.mkfifo(source)
                result = self.restore("gzip")
                self.assertEqual(result.returncode, 1, result.stderr)
                self.assertEqual((self.offline / "db.counters").read_text(), "original database")
                self.assertEqual((self.offline / "offline_db_usable.txt").read_text(), "original usability evidence")
                self.assertEqual(self.recorded_calls(), [])
                source.rmdir() if kind == "directory" else source.unlink()

    def test_restore_failure_is_not_reported_as_usable_or_successful(self):
        source = self.checkpoint()
        (self.offline / "offline_db_usable.txt").write_text("old usable marker")
        result = self.restore("gzip", TEST_RESTORE_FAILURE="1")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assert_single_restore(source)
        self.assertFalse((self.offline / "offline_db_usable.txt").exists())
        self.assertFalse(self.marker.exists())
        self.assertIn("injected actual restore failure", (self.logs / "recovery.log").read_text())


class LoaderCompletionPhases(RecoveryFixture):
    def setUp(self):
        super().setUp()
        source = (BIN / "load_checkpoint.sh").read_text()
        classify_start = source.index('if [[ "$ReplayJournalsOnly" -eq 0 ]]; then', source.index('Phase 1 - Preflight Checks.'))
        classify_end = source.index('# Set or check ServerID value.', classify_start)
        completion_start = source.index('if [[ "$ReplayJournalsOnly" -eq 0 ]]; then', source.index('Unexpected P4ROOT value of:'))
        summary = source.index('Preflight Summary:', completion_start)
        completion_end = source.rfind("\n", completion_start, summary) + 1
        replay_start = source.index('if [[ "$ReplayJournalsOnly" -eq 0 ]]; then', source.index('Phase 4 - Load Checkpoint in P4ROOT.'))
        replay_end = source.index('if [[ "$JournalCount" -gt 0 ]]; then', replay_start)
        self.phases = source[classify_start:classify_end] + source[completion_start:completion_end] + source[replay_start:replay_end]
        selector_start = source.index("function find_latest_checkpoint_md5 () {")
        selector_end = source.index("\n#------------------------------------------------------------------------------", selector_start)
        latest_start = source.index('if [[ "$GetLatestCheckpoint" -eq 1 ]]; then', source.index('# Find the newest checkpoint checksum'))
        latest_end = source.index('# If the user requested to find the latest journals', latest_start)
        self.latest = source[selector_start:selector_end] + source[latest_start:latest_end]

    def load(self, source, **extra):
        return self.run_shell('''msg () { log "$*"; }
bail () { log "$*"; exit 1; }
usage () { bail "$*"; }
ReplayJournalsOnly=0; GetLatestCheckpoint=0; LicenseFileNeeded=0
LoadParallelCheckpoint=0; Threads=4; DoParallelCheckpoints=1; NumCheckPointThreads=6
CompressFlag=; CaseFlag=; ReplicaTypeTag=ha; ExcludedTables=
Checkpoint=$TEST_CHECKPOINT
''' + self.phases, TEST_CHECKPOINT=str(source), **extra)

    def test_serial_and_parallel_load_execute_only_the_normal_restore(self):
        for kind in ("plain", "gzip", "parallel"):
            with self.subTest(kind=kind):
                source = self.checkpoint(kind)
                result = self.load(source)
                self.assertEqual(result.returncode, 0, result.stderr)
                expected = ["-r", str(self.database)]
                expected += ["-N", "6", "-jrp", str(source)] if kind == "parallel" else ["-jr", str(source)]
                self.assertEqual(self.recorded_calls(), [expected])
                self.calls.unlink()
                for path in self.checkpoints.iterdir():
                    shutil.rmtree(path) if path.is_dir() else path.unlink()

    def test_gzip_stem_sidecar_works_when_parent_directory_contains_dot_gz(self):
        source = self.checkpoint(sidecar="stem")
        result = self.load(source)
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(self.recorded_calls(), [["-r", str(self.database), "-jr", str(source)]])

    def test_missing_sidecar_plain_gzip_and_parallel_are_rejected_before_restore(self):
        for kind in ("plain", "gzip", "parallel"):
            with self.subTest(kind=kind):
                source = self.checkpoint(kind, sidecar="missing")
                result = self.load(source)
                self.assertEqual(result.returncode, 1, result.stderr)
                self.assertEqual(self.recorded_calls(), [])
                self.assertFalse((self.database / "db.counters").exists())
                shutil.rmtree(source) if source.is_dir() else source.unlink()

    def test_directory_named_md5_is_not_a_completion_file(self):
        source = self.checkpoint(sidecar="directory")
        result = self.load(source)
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.recorded_calls(), [])

    def test_unreadable_source_is_rejected_before_restore(self):
        source = self.checkpoint()
        source.unlink()
        source.symlink_to(self.checkpoints / "missing checkpoint")
        result = self.load(source)
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.recorded_calls(), [])

    def test_actual_restore_failure_remains_a_failure(self):
        source = self.checkpoint()
        result = self.load(source, TEST_RESTORE_FAILURE="1")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.recorded_calls(), [["-r", str(self.database), "-jr", str(source)]])
        self.assertIn("Checkpoint replay to P4ROOT failed", (self.logs / "recovery.log").read_text())

    def select_latest(self):
        return self.run_shell('''msg () { log "$*"; }
bail () { log "$*"; exit 1; }
GetLatestCheckpoint=1; LoadParallelCheckpoint=0
CheckpointsDir=$CHECKPOINTS; JournalPrefix="$CHECKPOINTS/$P4CKP_PREFIX"
''' + self.latest + '\nprintf "%s\\n" "$Checkpoint"\n')

    def test_latest_selector_ignores_directory_and_fifo_checksum_candidates(self):
        source = self.checkpoint()
        os.utime(Path(str(source) + ".md5"), (1, 1))
        (self.checkpoints / "p4_test.ckp.2.md5").mkdir()
        os.mkfifo(self.checkpoints / "p4_test.ckp.3.md5")
        result = self.select_latest()
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stdout.strip(), str(source))
        self.assertEqual(self.recorded_calls(), [])

    def test_latest_gzip_fallback_preserves_parent_directory_containing_md5(self):
        self.checkpoints = self.root / "checkpoints.md5-parent"
        self.checkpoints.mkdir()
        self.env["CHECKPOINTS"] = str(self.checkpoints)
        source = self.checkpoint(sidecar="stem")
        result = self.select_latest()
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stdout.strip(), str(source))

    def test_latest_selector_rejects_fifo_as_serial_checkpoint(self):
        source = self.checkpoint()
        source.unlink()
        os.mkfifo(source)
        result = self.select_latest()
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertEqual(self.recorded_calls(), [])


if __name__ == "__main__":
    unittest.main()
# Change User Description Committed
#1 34016 Russell C. Jackson (Rusty) Restore owned checkpoint-marker cleanup on handled failures and use MD5 completion markers for recovery.

Release only the acquiring shell’s ckp_running.txt marker in die(), including Bash 3.2 subshell ownership checks. Remove the separate full checkpoint verification pass from load_checkpoint and recreate_db_checkpoint, retain readable sidecar/source checks, and fix gzip sidecar path selection. Update regression tests and operational documentation.

Validation: combined acceptance gate passed; 44 platform stages, 30 topology scenarios, and 112 historical release scenarios across 14 RHEL 8/9 cells passed. All 8 Linux offline suites passed 526 tests each. Focused recovery tests passed on Bash 3.2 and 5.3.