add launcher
This commit is contained in:
+620
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JonasSearch Crawler Launcher
|
||||
|
||||
Starts and monitors the crawler and handles the generation-based
|
||||
rolling update mechanism (see crawler.py, section
|
||||
"DEPLOYMENT / UPDATE").
|
||||
|
||||
Division of labor:
|
||||
|
||||
crawler.py (old version)
|
||||
- remembers the deployment generation it started with
|
||||
- checks it before every relevant commit (safe point) and
|
||||
periodically in the background
|
||||
- when a new generation appears it rolls the open transaction
|
||||
back and exits with exit code 42 (EXIT_UPDATE_REQUIRED)
|
||||
|
||||
launcher.py (this file)
|
||||
- starts the crawler and waits for it to end
|
||||
- interprets the exit code:
|
||||
0 -> normal end (e.g. Ctrl+C): nothing to do
|
||||
42 -> update required: provision the new version,
|
||||
bump the generation, start the new version
|
||||
else-> error/crash: restart the crawler unchanged
|
||||
- update flow (in this order, so a broken update can never
|
||||
take the crawler down):
|
||||
1. stage the new version completely on this machine
|
||||
(releases/<VERSION>/crawler.py)
|
||||
2. verify it startable: the OLD crawler verifies the
|
||||
staged release via `--self-update VERSION`
|
||||
3. only then raise generation + required_version in
|
||||
PostgreSQL (--update-deployment VERSION)
|
||||
4. restart the crawler (now the new version), which
|
||||
reads the new generation at startup and runs on
|
||||
|
||||
Exit codes of the launcher itself mirror the crawler's: 0 normal end,
|
||||
42 update cycle completed, 1 configuration/provisioning error.
|
||||
|
||||
Usage:
|
||||
python3 launcher.py # start crawler (loop until Ctrl+C)
|
||||
python3 launcher.py --once # run one crawler cycle, then exit
|
||||
python3 launcher.py --check-update # only trigger the update flow
|
||||
python3 launcher.py --status # show crawler + deployment state
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
import signal
|
||||
import logging
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env loading mirrors crawler.py: working directory first, then the
|
||||
# launcher's own directory, so relative paths work from anywhere. An
|
||||
# unreadable .env must not crash the launcher.
|
||||
for _dotenv_path in (None, Path(__file__).resolve().parent / ".env"):
|
||||
try:
|
||||
load_dotenv(_dotenv_path)
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"[CONFIG] %s not readable (%s); continuing with the "
|
||||
"current environment.",
|
||||
_dotenv_path or ".env (working directory)",
|
||||
exc,
|
||||
)
|
||||
|
||||
# Configure logging BEFORE importing crawler.py - importing the module
|
||||
# would otherwise install its own logging.basicConfig first.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [launcher] %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("launcher")
|
||||
|
||||
# Make the crawler's CONFIG available (DB settings, CRAWLER_VERSION,
|
||||
# exit codes, CRAWLER_RELEASES_DIR). crawler.py is import-safe: all
|
||||
# side effects live under `if __name__ == "__main__":`.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import crawler as crawler_mod # noqa: E402
|
||||
|
||||
# crawler.py registers SIGINT/SIGTERM handlers at import time (for its
|
||||
# spawned workers). The launcher needs the default behavior back so
|
||||
# Ctrl+C raises KeyboardInterrupt here; the crawler child processes
|
||||
# spawn fresh and get their own handlers.
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||||
|
||||
# ============================================================
|
||||
# CONFIG
|
||||
# ============================================================
|
||||
|
||||
# The crawler file this launcher starts (active/old version).
|
||||
CRAWLER_ENTRY = Path(
|
||||
os.getenv(
|
||||
"JONASSEARCH_LAUNCHER_ENTRY",
|
||||
str(Path(__file__).resolve().parent / "crawler.py"),
|
||||
)
|
||||
)
|
||||
|
||||
# Where new versions are staged before activation.
|
||||
# crawler.py resolves relative JONASSEARCH_RELEASES_DIR paths against
|
||||
# the crawler file's directory - mirror that here so both agree.
|
||||
RELEASES_DIR = crawler_mod.CRAWLER_RELEASES_DIR
|
||||
|
||||
# Index of the currently active version, kept next to the crawler.
|
||||
ACTIVE_MARKER = CRAWLER_ENTRY.parent / ".active-crawler-version"
|
||||
|
||||
# How new versions arrive on this machine (step 16 of the concept -
|
||||
# deliberately kept simple and pluggable). Supported sources:
|
||||
# - JONASSEARCH_UPDATE_SOURCE_URL: HTTP(S) download of crawler.py
|
||||
# (the file is downloaded fresh for every update, so no local
|
||||
# cache is needed)
|
||||
# - JONASSEARCH_UPDATE_SOURCE_DIR: local directory containing
|
||||
# crawler.py (e.g. a shared folder or a git checkout)
|
||||
# Without a source the launcher can only verify and activate a release
|
||||
# that was placed into releases/<VERSION>/crawler.py externally.
|
||||
UPDATE_SOURCE_URL = os.getenv("JONASSEARCH_UPDATE_SOURCE_URL", "").strip()
|
||||
UPDATE_SOURCE_DIR = os.getenv("JONASSEARCH_UPDATE_SOURCE_DIR", "").strip()
|
||||
|
||||
# Restart behavior after a crash (not an update!).
|
||||
RESTART_DELAY_ON_ERROR = float(
|
||||
os.getenv("JONASSEARCH_LAUNCHER_ERROR_RESTART_DELAY", "10")
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HELPERS
|
||||
# ============================================================
|
||||
|
||||
def run_crawler_command(entry, *args, timeout=None):
|
||||
"""Run a crawler.py subcommand and return the CompletedProcess."""
|
||||
return subprocess.run(
|
||||
[sys.executable, str(entry), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def read_active_version():
|
||||
"""Version number of the currently active crawler (marker file)."""
|
||||
try:
|
||||
return int(ACTIVE_MARKER.read_text(encoding="utf-8").strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_active_version(version):
|
||||
ACTIVE_MARKER.write_text(f"{version}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def read_installed_version(entry):
|
||||
"""Ask the crawler itself which version it is (source of truth)."""
|
||||
try:
|
||||
result = run_crawler_command(entry, "--version", timeout=30)
|
||||
except Exception as exc:
|
||||
log.error("Could not query crawler version (%s): %s", entry, exc)
|
||||
return None
|
||||
if result.returncode != crawler_mod.EXIT_OK:
|
||||
log.error(
|
||||
"crawler.py --version failed (exit %d): %s",
|
||||
result.returncode,
|
||||
(result.stderr or "").strip()[-300:],
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return int((result.stdout or "").strip())
|
||||
except ValueError:
|
||||
log.error(
|
||||
"crawler.py --version printed no version number: %r",
|
||||
(result.stdout or "")[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_remote_version():
|
||||
"""Ask the update source which version it offers (may be None)."""
|
||||
if UPDATE_SOURCE_URL:
|
||||
try:
|
||||
req = Request(UPDATE_SOURCE_URL, headers={"User-Agent": "launcher"})
|
||||
with urlopen(req, timeout=crawler_mod.REQUEST_TIMEOUT) as resp:
|
||||
text = resp.read(1024 * 1024).decode("utf-8", errors="replace")
|
||||
return int(text.strip())
|
||||
except (URLError, ValueError) as exc:
|
||||
log.error(
|
||||
"Could not query update source %s: %s", UPDATE_SOURCE_URL, exc
|
||||
)
|
||||
return None
|
||||
if UPDATE_SOURCE_DIR:
|
||||
version_file = Path(UPDATE_SOURCE_DIR) / "VERSION"
|
||||
try:
|
||||
return int(version_file.read_text(encoding="utf-8").strip())
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Could not read VERSION in update source %s: %s",
|
||||
version_file,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# UPDATE FLOW (steps 8, 9 and 15 of the concept)
|
||||
# ============================================================
|
||||
|
||||
def stage_release(version):
|
||||
"""
|
||||
Step 1: make the new version fully available on this machine,
|
||||
WITHOUT activating it: releases/<VERSION>/crawler.py.
|
||||
|
||||
Already staged releases are kept (idempotent). Without a configured
|
||||
update source the release must already exist here.
|
||||
"""
|
||||
target_dir = RELEASES_DIR / str(version)
|
||||
target_file = target_dir / "crawler.py"
|
||||
|
||||
if target_file.is_file():
|
||||
log.info("Release %d already staged at %s.", version, target_file)
|
||||
return target_file
|
||||
|
||||
if UPDATE_SOURCE_URL:
|
||||
base = UPDATE_SOURCE_URL.rsplit("/", 1)[0]
|
||||
file_url = f"{base}/{version}/crawler.py"
|
||||
log.info("Downloading release %d from %s ...", version, file_url)
|
||||
req = Request(file_url, headers={"User-Agent": "launcher"})
|
||||
with urlopen(req, timeout=crawler_mod.REQUEST_TIMEOUT) as resp:
|
||||
data = resp.read(64 * 1024 * 1024)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_file = target_dir / "crawler.py.part"
|
||||
tmp_file.write_bytes(data)
|
||||
# Only complete files get their final name.
|
||||
tmp_file.replace(target_file)
|
||||
log.info("Release %d staged at %s.", version, target_file)
|
||||
return target_file
|
||||
|
||||
if UPDATE_SOURCE_DIR:
|
||||
source_file = Path(UPDATE_SOURCE_DIR) / "crawler.py"
|
||||
if not source_file.is_file():
|
||||
log.error("crawler.py missing in update source %s.", source_file)
|
||||
return None
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_file, target_file)
|
||||
log.info("Release %d staged at %s.", version, target_file)
|
||||
return target_file
|
||||
|
||||
log.error(
|
||||
"Release %d is not staged (%s) and no update source is "
|
||||
"configured (JONASSEARCH_UPDATE_SOURCE_URL or "
|
||||
"JONASSEARCH_UPDATE_SOURCE_DIR). The generation was NOT "
|
||||
"changed.",
|
||||
version,
|
||||
target_file,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def verify_release(version):
|
||||
"""
|
||||
Step 2: verify the staged release is complete and startable -
|
||||
executed by the CURRENT (old) crawler, before anything is switched.
|
||||
If this fails, the old version stays active (fallback guarantee).
|
||||
"""
|
||||
result = run_crawler_command(
|
||||
CRAWLER_ENTRY, "--self-update", str(version), timeout=120
|
||||
)
|
||||
if result.returncode != crawler_mod.EXIT_OK:
|
||||
log.error(
|
||||
"Release %d FAILED the verification; nothing was switched.\n%s",
|
||||
version,
|
||||
(result.stdout or "") + (result.stderr or ""),
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def activate_release(version, staged_file):
|
||||
"""
|
||||
Step 3: switch the active crawler to the verified release.
|
||||
|
||||
The old file is kept as crawler.py.bak-<VERSION> so there is always
|
||||
a working fallback. The marker file records the active version;
|
||||
the database switch itself happens in bump_deployment().
|
||||
"""
|
||||
if staged_file.resolve() == CRAWLER_ENTRY.resolve():
|
||||
write_active_version(version)
|
||||
return True
|
||||
|
||||
backup = CRAWLER_ENTRY.with_name(
|
||||
"crawler.py.bak-{}".format(
|
||||
read_active_version()
|
||||
or read_installed_version(CRAWLER_ENTRY)
|
||||
or "unknown"
|
||||
)
|
||||
)
|
||||
try:
|
||||
shutil.copy2(CRAWLER_ENTRY, backup)
|
||||
shutil.copy2(staged_file, CRAWLER_ENTRY)
|
||||
except OSError as exc:
|
||||
log.error(
|
||||
"Switching to release %d failed (%s); the old version "
|
||||
"stays active.",
|
||||
version,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Sanity check: the switched file must identify itself correctly.
|
||||
installed = read_installed_version(CRAWLER_ENTRY)
|
||||
if installed != version:
|
||||
log.error(
|
||||
"Switched crawler reports version %s instead of %d; "
|
||||
"restoring the backup.",
|
||||
installed,
|
||||
version,
|
||||
)
|
||||
shutil.copy2(backup, CRAWLER_ENTRY)
|
||||
return False
|
||||
|
||||
write_active_version(version)
|
||||
log.info(
|
||||
"Release %d activated (backup of the old version: %s).",
|
||||
version,
|
||||
backup,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def bump_deployment(version):
|
||||
"""
|
||||
Step 4 - the global switch point: generation + 1 and
|
||||
required_version = version in PostgreSQL. Only AFTER this moment do
|
||||
running old crawlers notice the update (at their next safe commit
|
||||
point) and exit with code 42.
|
||||
"""
|
||||
result = run_crawler_command(
|
||||
CRAWLER_ENTRY, "--update-deployment", str(version), timeout=60
|
||||
)
|
||||
if result.returncode != crawler_mod.EXIT_OK:
|
||||
log.error(
|
||||
"Deployment update to version %d failed:\n%s",
|
||||
version,
|
||||
(result.stderr or "")[-500:],
|
||||
)
|
||||
return False
|
||||
log.info((result.stdout or "").strip())
|
||||
return True
|
||||
|
||||
|
||||
def perform_update(required_version):
|
||||
"""
|
||||
Full update flow in the safe order (concept steps 8, 9 and 15):
|
||||
|
||||
stage -> verify -> activate -> bump generation
|
||||
|
||||
Returns True only if every step succeeded.
|
||||
"""
|
||||
log.info("Update to crawler version %d starting ...", required_version)
|
||||
|
||||
staged = stage_release(required_version)
|
||||
if staged is None:
|
||||
return False
|
||||
|
||||
if not verify_release(required_version):
|
||||
return False
|
||||
|
||||
if not activate_release(required_version, staged):
|
||||
return False
|
||||
|
||||
if not bump_deployment(required_version):
|
||||
return False
|
||||
|
||||
log.info("Update to crawler version %d completed.", required_version)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# LAUNCH LOOP
|
||||
# ============================================================
|
||||
|
||||
def start_crawler_process():
|
||||
log.info(
|
||||
"Starting crawler: %s (version %s)",
|
||||
CRAWLER_ENTRY,
|
||||
read_installed_version(CRAWLER_ENTRY),
|
||||
)
|
||||
# The crawler inherits stdout/stderr, so its logs stay visible.
|
||||
return subprocess.Popen([sys.executable, str(CRAWLER_ENTRY)])
|
||||
|
||||
|
||||
def wait_for_process(process):
|
||||
"""Block until the process ends; return its exit code."""
|
||||
try:
|
||||
return process.wait()
|
||||
except KeyboardInterrupt:
|
||||
log.info("Ctrl+C received; stopping crawler ...")
|
||||
process.terminate()
|
||||
try:
|
||||
return process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return process.wait()
|
||||
|
||||
|
||||
def read_required_version():
|
||||
"""
|
||||
Read required_version from deployment_state (id = 1). Returns None
|
||||
on database errors or a missing row.
|
||||
"""
|
||||
try:
|
||||
conn = crawler_mod.db_connect()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT required_version FROM deployment_state "
|
||||
"WHERE id = 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
log.error("Deployment state could not be read: %s", exc)
|
||||
return None
|
||||
return int(row[0]) if row else None
|
||||
|
||||
|
||||
def check_and_update():
|
||||
"""
|
||||
Compare installed version with the required version from
|
||||
PostgreSQL and run the update flow when necessary.
|
||||
"""
|
||||
try:
|
||||
generation = crawler_mod.get_current_generation()
|
||||
except Exception as exc:
|
||||
log.error("Deployment state could not be read: %s", exc)
|
||||
return False
|
||||
|
||||
row_required = read_required_version()
|
||||
if row_required is None:
|
||||
log.error("deployment_state has no row with id = 1.")
|
||||
return False
|
||||
|
||||
required_version = row_required
|
||||
installed = read_installed_version(CRAWLER_ENTRY)
|
||||
remote = fetch_remote_version()
|
||||
|
||||
log.info(
|
||||
"Deployment: generation %d | required_version %d | "
|
||||
"installed %s | update source offers %s",
|
||||
generation,
|
||||
required_version,
|
||||
installed,
|
||||
remote,
|
||||
)
|
||||
|
||||
if installed is None:
|
||||
return False
|
||||
|
||||
if installed != required_version:
|
||||
# The crawler would exit with 42 immediately anyway - provide
|
||||
# the new version first so the restart is seamless.
|
||||
return perform_update(required_version)
|
||||
|
||||
if remote is not None and remote > installed:
|
||||
# New version announced but not yet deployed: stage it now and
|
||||
# only then flip the generation (concept step 16).
|
||||
return perform_update(remote)
|
||||
|
||||
log.info("Crawler is up to date.")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="launcher.py",
|
||||
description="Starts and monitors the JonasSearch crawler and "
|
||||
"handles generation-based rolling updates.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="run one crawler cycle and exit (no restart loop)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-update",
|
||||
action="store_true",
|
||||
help="check for a new version, run the update flow if needed "
|
||||
"and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="show deployment state, installed version and exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.status:
|
||||
try:
|
||||
generation = crawler_mod.get_current_generation()
|
||||
except Exception as exc:
|
||||
log.error("Deployment state could not be read: %s", exc)
|
||||
return crawler_mod.EXIT_ERROR
|
||||
installed = read_installed_version(CRAWLER_ENTRY)
|
||||
required_version = read_required_version()
|
||||
print(
|
||||
f"generation: {generation}\n"
|
||||
f"required_version: {required_version}\n"
|
||||
f"installed: {installed}\n"
|
||||
f"crawler file: {CRAWLER_ENTRY}\n"
|
||||
f"releases dir: {RELEASES_DIR}"
|
||||
)
|
||||
return (
|
||||
crawler_mod.EXIT_OK
|
||||
if required_version is not None
|
||||
else crawler_mod.EXIT_ERROR
|
||||
)
|
||||
|
||||
if args.check_update:
|
||||
return crawler_mod.EXIT_OK if check_and_update() else crawler_mod.EXIT_ERROR
|
||||
|
||||
log.info(
|
||||
"Launcher started. Crawler: %s | releases: %s",
|
||||
CRAWLER_ENTRY,
|
||||
RELEASES_DIR,
|
||||
)
|
||||
|
||||
update_cycle_ran = False
|
||||
|
||||
while True:
|
||||
process = start_crawler_process()
|
||||
exit_code = wait_for_process(process)
|
||||
|
||||
if exit_code == crawler_mod.EXIT_UPDATE_REQUIRED:
|
||||
# The crawler reached a safe point itself and wants the
|
||||
# update. New version first, then the generation bump, then
|
||||
# the restart (see perform_update()).
|
||||
log.warning(
|
||||
"Crawler exited with code %d: update required.",
|
||||
crawler_mod.EXIT_UPDATE_REQUIRED,
|
||||
)
|
||||
|
||||
required_version = read_required_version()
|
||||
|
||||
if required_version is None:
|
||||
log.error(
|
||||
"Cannot determine the required version; starting the "
|
||||
"existing crawler again."
|
||||
)
|
||||
time.sleep(RESTART_DELAY_ON_ERROR)
|
||||
continue
|
||||
|
||||
installed = read_installed_version(CRAWLER_ENTRY)
|
||||
if installed == required_version:
|
||||
# Rare case: the version was already switched externally
|
||||
# (e.g. by --check-update) - nothing to provision.
|
||||
log.info("Version %d already active.", required_version)
|
||||
elif not perform_update(required_version):
|
||||
log.error(
|
||||
"Update failed. Starting the existing version again "
|
||||
"after %ds (it will exit with 42 at the next safe "
|
||||
"point and the update will be retried).",
|
||||
RESTART_DELAY_ON_ERROR,
|
||||
)
|
||||
time.sleep(RESTART_DELAY_ON_ERROR)
|
||||
continue
|
||||
|
||||
update_cycle_ran = True
|
||||
|
||||
if args.once:
|
||||
# One cycle only: report the update to the caller
|
||||
# (exit code 42) instead of restarting ourselves.
|
||||
break
|
||||
|
||||
try:
|
||||
current_generation = crawler_mod.get_current_generation()
|
||||
except Exception:
|
||||
current_generation = "?"
|
||||
log.info(
|
||||
"Restarting the crawler (it reads generation %s at "
|
||||
"startup) ...",
|
||||
current_generation,
|
||||
)
|
||||
time.sleep(crawler_mod.LAUNCHER_RESTART_DELAY)
|
||||
continue
|
||||
|
||||
if exit_code == crawler_mod.EXIT_OK:
|
||||
# Normal end (e.g. Ctrl+C on the crawler): do not restart.
|
||||
log.info("Crawler ended normally (exit 0).")
|
||||
break
|
||||
|
||||
# Anything else is an error/crash - NOT an update.
|
||||
log.error(
|
||||
"Crawler crashed (exit code %s). Restart in %ds ...",
|
||||
exit_code,
|
||||
RESTART_DELAY_ON_ERROR,
|
||||
)
|
||||
time.sleep(RESTART_DELAY_ON_ERROR)
|
||||
|
||||
if args.once:
|
||||
break
|
||||
|
||||
log.info("Launcher stopped.")
|
||||
return (
|
||||
crawler_mod.EXIT_UPDATE_REQUIRED
|
||||
if update_cycle_ran
|
||||
else crawler_mod.EXIT_OK
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user