add launcher

This commit is contained in:
2026-09-13 01:57:46 +02:00
parent 2e1701c442
commit 682c0f4971
6 changed files with 2165 additions and 209 deletions
+28
View File
@@ -26,6 +26,34 @@ JONASSEARCH_MAX_CONTENT_LENGTH=5242880
# User-Agent # User-Agent
JONASSEARCH_USER_AGENT=JonasSearchBot/0.1 (+https://jonassearch.local/bot) JONASSEARCH_USER_AGENT=JonasSearchBot/0.1 (+https://jonassearch.local/bot)
# ============================================================
# Rolling Updates (Launcher + Crawler)
# ============================================================
# Version dieser Crawler-Codebasis (NICHT die Deployment-Generation).
# Muss bei jedem verteilten Crawler-Änderung erhöht werden.
JONASSEARCH_CRAWLER_VERSION=1
# Wie oft jeder Crawler außerhalb der Transaktionen nach einer neuen
# Deployment-Generation sieht (Sekunden). Der Check setzt nur ein
# internes Signal; beendet wird sich erst am nächsten sicheren Commit.
JONASSEARCH_UPDATE_CHECK_SECONDS=30
# Verzeichnis für gestagte Crawler-Releases (releases/<VERSION>/crawler.py).
# Relative Pfade gelten relativ zur crawler.py.
#JONASSEARCH_RELEASES_DIR=releases
# Launcher: Wohin mit neuen Versionen? Eine der beiden Quellen setzen,
# sonst kann der Launcher nur extern abgelegte Releases prüfen/aktivieren.
#JONASSEARCH_UPDATE_SOURCE_URL=https://example.com/jonassearch/releases
#JONASSEARCH_UPDATE_SOURCE_DIR=/srv/jonassearch/new-release
# Launcher: Wartezeit nach Crash (Sekunden), bevor der Crawler neu startet
#JONASSEARCH_LAUNCHER_ERROR_RESTART_DELAY=10
# Launcher: Wartezeit vor dem Neustart nach einem Update-Zyklus
#JONASSEARCH_LAUNCHER_RESTART_DELAY=2
# ============================================================ # ============================================================
# Suchmaschine (search/) # Suchmaschine (search/)
# ============================================================ # ============================================================
+5
View File
@@ -178,3 +178,8 @@ cython_debug/
web/page_counts.json web/page_counts.json
# Crawler rolling updates
releases/
.active-crawler-version
crawler.py.bak-*
+43
View File
@@ -3,5 +3,48 @@
das komplette serverjonas search das komplette serverjonas search
- `crawler.py` – PostgreSQL-Crawler, baut den Suchindex auf - `crawler.py` – PostgreSQL-Crawler, baut den Suchindex auf
- `launcher.py` – startet/überwacht den Crawler und rollt Updates aus (siehe unten)
- `search/` – Suchmaschine darüber (CLI + HTTP-API), siehe [search/README.md](search/README.md) - `search/` – Suchmaschine darüber (CLI + HTTP-API), siehe [search/README.md](search/README.md)
- `web/` – Statistik-Webseite - `web/` – Statistik-Webseite
## Rolling Updates (Generation-Mechanismus)
Zentrale Tabelle (eine Zeile, `id = 1`):
```sql
CREATE TABLE IF NOT EXISTS deployment_state (
id INTEGER PRIMARY KEY,
generation BIGINT NOT NULL,
required_version INTEGER NOT NULL
);
```
**Ablauf:**
1. Einmalig initialisieren: `python3 crawler.py --init-deployment`
2. Launcher starten: `python3 launcher.py` (startet + überwacht den Crawler)
3. Neue Version verteilen:
- Neue `crawler.py` mit erhöhter `JONASSEARCH_CRAWLER_VERSION` bereitstellen
(Update-Quelle im `.env` setzen oder Datei nach `releases/<VERSION>/crawler.py` legen)
- `python3 launcher.py --check-update`
→ neue Version wird gestaget → geprüft (`--self-update`) → aktiviert
→ **erst dann** wird die Generation in PostgreSQL erhöht (`--update-deployment`)
4. Alle laufenden alten Crawler merken das beim nächsten sicheren Punkt
(vor jedem relevanten Commit bzw. spätestens nach
`JONASSEARCH_UPDATE_CHECK_SECONDS` im Hintergrund-Check),
rollen ihre offene Transaktion sauber zurück und beenden sich mit
**Exit-Code 42** – nie mitten in einer Transaktion, kein hartes Killen.
5. Der jeweilige Launcher erkennt 42 und startet die bereits bereitgestellte
neue Version, die die neue Generation beim Start liest.
Exit-Codes des Crawlers: `0` = normal beendet, `1` = Fehler/Crash,
`42` = ausschließlich „Update erforderlich".
Nützliche Befehle:
```bash
python3 crawler.py --version # installierte Version
python3 launcher.py --status # Generation + Versionen anzeigen
python3 crawler.py --self-update 2 # gestagte Release prüfen (ohne zu aktivieren)
python3 crawler.py --update-deployment 2 # Generation+1 und required_version=2 setzen
```
+699 -30
View File
@@ -18,6 +18,13 @@ Features:
- robots.txt support with in-memory cache - robots.txt support with in-memory cache
- Graceful shutdown - Graceful shutdown
- Basic crawler statistics - Basic crawler statistics
- Generation-based rolling updates: the crawler remembers the
deployment generation it started with, checks it before every
relevant commit (safe point) and exits with exit code 42 when a new
generation requires an update - the open transaction is rolled
back, never half-committed
- Launcher support: --version, --self-update N (verify a staged
release) and --update-deployment N (bump generation + required_version)
Expected PostgreSQL schema: Expected PostgreSQL schema:
pages pages
@@ -30,6 +37,8 @@ Connection defaults can be overridden with environment variables:
JONASSEARCH_DB_NAME JONASSEARCH_DB_NAME
JONASSEARCH_DB_USER JONASSEARCH_DB_USER
JONASSEARCH_DB_PASSWORD JONASSEARCH_DB_PASSWORD
JONASSEARCH_UPDATE_CHECK_SECONDS
JONASSEARCH_RELEASES_DIR
Example: Example:
python3 crawler.py python3 crawler.py
@@ -37,10 +46,14 @@ Example:
import os import os
import re import re
import sys
import math import math
import signal import signal
import time import time
import logging import logging
import argparse
import subprocess
import threading
import multiprocessing as mp import multiprocessing as mp
from collections import defaultdict from collections import defaultdict
from html import unescape from html import unescape
@@ -59,11 +72,29 @@ try:
except ImportError: except ImportError:
BeautifulSoup = None BeautifulSoup = None
# .env zuerst aus dem Arbeitsverzeichnis laden (Standard), zusätzlich aus def _load_env_files():
# dem Projektordner – so findet der Prozess die Konfiguration auch dann, """
# wenn er von woanders gestartet wird (z. B. via systemd). .env zuerst aus dem Arbeitsverzeichnis laden (Standard), zusätzlich
load_dotenv() aus dem Projektordner – so findet der Prozess die Konfiguration
load_dotenv(Path(__file__).resolve().parent / ".env") auch dann, wenn er von woanders gestartet wird (z. B. via systemd).
Eine nicht lesbare .env darf den Crawler nicht abstürzen lassen
(z. B. falscher Benutzer unter systemd): dann laufen die bereits
gesetzten Umgebungsvariablen unverändert weiter.
"""
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,
)
_load_env_files()
# ============================================================ # ============================================================
@@ -99,6 +130,46 @@ USER_AGENT = os.getenv(
"JonasSearchBot/0.1 (+https://jonassearch.local/bot)" "JonasSearchBot/0.1 (+https://jonassearch.local/bot)"
) )
# ============================================================
# DEPLOYMENT / UPDATE CONFIG
# ============================================================
# Version of THIS crawler code. Increased with every released crawler
# change; completely independent from the deployment generation (the
# generation is a global deployment counter in PostgreSQL, the version
# identifies the crawler code).
CRAWLER_VERSION = int(os.getenv("JONASSEARCH_CRAWLER_VERSION", "1"))
# Exit codes. 42 is reserved exclusively for "I must be updated" so
# the launcher can distinguish update, crash and normal exit.
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_UPDATE_REQUIRED = 42
# How often the background checker re-reads the generation. It only
# sets an internal flag; the actual exit happens at the next safe
# commit point, never in the middle of a transaction.
UPDATE_CHECK_INTERVAL_SECONDS = float(
os.getenv("JONASSEARCH_UPDATE_CHECK_SECONDS", "30")
)
# Staging directory for updates: the launcher stages a new version in
# releases/<VERSION>/crawler.py, verifies it with --self-update and
# only then activates it. Relative paths resolve against the crawler
# file's directory, so they work from any working directory.
_RELEASES_RAW = os.getenv("JONASSEARCH_RELEASES_DIR", "releases")
CRAWLER_RELEASES_DIR = Path(_RELEASES_RAW)
if not CRAWLER_RELEASES_DIR.is_absolute():
CRAWLER_RELEASES_DIR = (
Path(__file__).resolve().parent / CRAWLER_RELEASES_DIR
)
# How long the launcher waits before restarting the crawler after it
# exited with EXIT_UPDATE_REQUIRED.
LAUNCHER_RESTART_DELAY = float(
os.getenv("JONASSEARCH_LAUNCHER_RESTART_DELAY", "2")
)
# Only crawl normal HTTP(S) pages. # Only crawl normal HTTP(S) pages.
ALLOWED_SCHEMES = {"http", "https"} ALLOWED_SCHEMES = {"http", "https"}
@@ -223,6 +294,252 @@ HOST_STATS_CACHE_SECONDS = 30
HOST_STATS_SAMPLE_LIMIT = 20000 HOST_STATS_SAMPLE_LIMIT = 20000
# ============================================================
# DEPLOYMENT / UPDATE (generation-based rolling updates)
# ============================================================
#
# The deployment_state table is the global switch for rolling
# updates (exactly one row, id = 1):
#
# CREATE TABLE IF NOT EXISTS deployment_state (
# id INTEGER PRIMARY KEY,
# generation BIGINT NOT NULL,
# required_version INTEGER NOT NULL
# )
#
# - generation: global deployment counter. The launcher bumps
# it when a new crawler version goes live.
# - required_version: the crawler version this generation requires.
#
# Every crawler instance reads the generation ONCE at startup ("my
# generation is N"). Before every relevant PostgreSQL commit
# (safe_commit) and additionally in a periodic background check it
# compares the current generation with its own:
#
# - identical -> everything fine, continue.
# - different -> the open transaction is rolled back cleanly and the
# process exits with EXIT_UPDATE_REQUIRED (42). The
# launcher recognizes that exit code, provisions the
# new version and starts it; the new process reads the
# new generation at startup and runs on.
#
# No process is ever killed mid-transaction: exits happen only at the
# crawler's own safe commit points.
#
# CRAWLER_VERSION (CONFIG section) is the version of THIS crawler
# code. It is NOT the generation and must be increased with every
# released crawler change.
class UpdateRequired(Exception):
"""
Raised at a safe point (before a commit) when a new deployment
generation is active. safe_commit() rolls the open transaction
back BEFORE raising, so no work is ever committed half-done.
"""
class UpdateSignal:
"""
Per-process update state (every crawler worker process owns one).
generation: the deployment generation this process started with.
pending: True once a check noticed a new generation; the
process stops at the next safe commit point.
stop_checker: tells the background checker thread to finish.
"""
def __init__(self):
self.generation = None
self.pending = False
self.stop_checker = False
def check(self):
"""Raise UpdateRequired if an update is pending."""
if self.pending:
raise UpdateRequired(
"new deployment generation active; update required"
)
update_signal = UpdateSignal()
def ensure_deployment_state_table(conn):
"""
Idempotent bootstrap for deployment_state (fresh databases):
creates the table if needed and makes sure exactly one row with
id = 1 exists. Safe to run from several crawler instances at once
(CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING).
"""
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS deployment_state (
id INTEGER PRIMARY KEY,
generation BIGINT NOT NULL,
required_version INTEGER NOT NULL
)
""")
cur.execute("""
INSERT INTO deployment_state (id, generation, required_version)
VALUES (1, 1, %s)
ON CONFLICT (id) DO NOTHING
""", (CRAWLER_VERSION,))
conn.commit()
def _fetch_generation_row(conn):
"""
Read the deployment_state row (id = 1). If the table does not exist
yet, it is bootstrapped (see ensure_deployment_state_table) and the
row is read again. Returns the row or None (table without row).
"""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT generation FROM deployment_state WHERE id = 1"
)
row = cur.fetchone()
conn.rollback() # read-only; leave no open transaction behind
return row
except psycopg2.errors.UndefinedTable:
conn.rollback()
logging.warning(
"deployment_state table missing; creating it with "
"generation 1 and required_version %d.",
CRAWLER_VERSION,
)
ensure_deployment_state_table(conn)
with conn.cursor() as cur:
cur.execute(
"SELECT generation FROM deployment_state WHERE id = 1"
)
row = cur.fetchone()
conn.rollback()
return row
def get_current_generation(conn=None):
"""
Read the current deployment generation. Called once per process at
startup: this becomes "my generation" (update_signal.generation).
"""
own_conn = conn is None
if own_conn:
conn = db_connect()
try:
row = _fetch_generation_row(conn)
if row is None:
logging.warning(
"deployment_state has no row with id = 1; inserting "
"generation 1 with required_version %d.",
CRAWLER_VERSION,
)
ensure_deployment_state_table(conn)
row = _fetch_generation_row(conn)
return int(row[0])
finally:
if own_conn:
conn.close()
def periodic_update_check():
"""
Regular update check OUTSIDE any transaction, so instances that
currently do not commit still notice a new generation.
Runs as a daemon thread per worker and uses its own short-lived
connection, so it can never interfere with the worker's open
transaction. A new generation only sets update_signal.pending;
the actual exit happens at the next safe commit point. A failed
check (e.g. short DB hiccup) is simply retried next interval.
"""
while not (
STOP.is_set()
or update_signal.pending
or update_signal.stop_checker
):
time.sleep(UPDATE_CHECK_INTERVAL_SECONDS)
if STOP.is_set() or update_signal.pending or update_signal.stop_checker:
break
try:
conn = db_connect()
except Exception:
continue
try:
with conn.cursor() as cur:
cur.execute(
"SELECT generation FROM deployment_state WHERE id = 1"
)
row = cur.fetchone()
conn.rollback()
except Exception:
try:
conn.rollback()
except Exception:
pass
continue
finally:
try:
conn.close()
except Exception:
pass
if row is None:
continue
try:
current = int(row[0])
except (TypeError, ValueError):
continue
if (
not update_signal.pending
and update_signal.generation is not None
and current != update_signal.generation
):
update_signal.pending = True
logging.warning(
"[UPDATE] Deployment generation changed: %d -> %d. "
"This worker (version %d) stops at the next safe "
"commit point.",
update_signal.generation,
current,
CRAWLER_VERSION,
)
def safe_commit(conn, operation=None):
"""
The ONLY commit path for crawling work.
1. If an update is pending, roll the open transaction back and
raise UpdateRequired: the caller's writes are discarded
cleanly, nothing is committed half-done.
2. Otherwise run the writes (operation) and commit them.
Every crawler commit that does real work goes through here
(page saves, queue inserts, queue claim/finish, stats), so a
deployment switch can never cut a transaction in half. The
one-off startup commits (seeding, stats table) are routed
through this function as well.
"""
if update_signal.pending:
try:
conn.rollback()
except Exception:
logging.exception("Rollback before update exit failed.")
raise UpdateRequired(
"new deployment generation active; update required"
)
result = operation() if operation is not None else None
conn.commit()
return result
# ============================================================ # ============================================================
# LOGGING / SHUTDOWN # LOGGING / SHUTDOWN
# ============================================================ # ============================================================
@@ -262,6 +579,8 @@ def db_connect():
def ensure_stats_table(conn): def ensure_stats_table(conn):
"""Create crawler_stats. Writes only - commit happens in
safe_commit()."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS crawler_stats ( CREATE TABLE IF NOT EXISTS crawler_stats (
@@ -272,16 +591,16 @@ def ensure_stats_table(conn):
failed INTEGER NOT NULL DEFAULT 0 failed INTEGER NOT NULL DEFAULT 0
) )
""") """)
conn.commit()
def write_stats(conn, crawled, queued, failed): def write_stats(conn, crawled, queued, failed):
"""Insert one stats row. Writes only - commit happens in
safe_commit()."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
INSERT INTO crawler_stats (crawled, queued, failed) INSERT INTO crawler_stats (crawled, queued, failed)
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
""", (crawled, queued, failed)) """, (crawled, queued, failed))
conn.commit()
def get_next_queue_item(conn): def get_next_queue_item(conn):
@@ -290,6 +609,8 @@ def get_next_queue_item(conn):
SKIP LOCKED is the important part: several crawler processes can SKIP LOCKED is the important part: several crawler processes can
consume the same PostgreSQL queue without waiting for each other. consume the same PostgreSQL queue without waiting for each other.
Writes only - the commit happens in safe_commit().
""" """
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
@@ -310,23 +631,25 @@ def get_next_queue_item(conn):
RETURNING q.id, q.url, q.source_url RETURNING q.id, q.url, q.source_url
""") """)
row = cur.fetchone() row = cur.fetchone()
conn.commit()
return row return row
def finish_queue_item(conn, queue_id, success=True): def finish_queue_item(conn, queue_id, success=True):
"""Mark a queue item done/failed. Writes only - commit happens in
safe_commit()."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
UPDATE crawl_queue UPDATE crawl_queue
SET status = %s SET status = %s
WHERE id = %s WHERE id = %s
""", ("done" if success else "failed", queue_id)) """, ("done" if success else "failed", queue_id))
conn.commit()
def requeue_stale_items(conn, older_than_seconds=3600): def requeue_stale_items(conn, older_than_seconds=3600):
""" """
Recover URLs left in 'processing' after a crash. Recover URLs left in 'processing' after a crash.
Writes only - commit happens in safe_commit().
""" """
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
@@ -336,7 +659,6 @@ def requeue_stale_items(conn, older_than_seconds=3600):
AND last_attempt_at < now() - (%s * interval '1 second') AND last_attempt_at < now() - (%s * interval '1 second')
""", (older_than_seconds,)) """, (older_than_seconds,))
changed = cur.rowcount changed = cur.rowcount
conn.commit()
if changed: if changed:
logging.info("Requeued %d stale processing URLs.", changed) logging.info("Requeued %d stale processing URLs.", changed)
@@ -346,9 +668,10 @@ def _insert_queue_rows(conn, rows):
Insert pre-ordered queue rows and return the number of newly Insert pre-ordered queue rows and return the number of newly
inserted rows. inserted rows.
All pages run inside ONE transaction (commit only at the end), so All pages run inside ONE transaction (committed by safe_commit()
the batch stays atomic as before. rowcount is accumulated per page after this function returns), so the batch stays atomic as before.
because execute_values() does not aggregate rowcount across pages. rowcount is accumulated per page because execute_values() does not
aggregate rowcount across pages.
""" """
inserted = 0 inserted = 0
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -367,7 +690,6 @@ def _insert_queue_rows(conn, rows):
) )
inserted += max(cur.rowcount, 0) inserted += max(cur.rowcount, 0)
conn.commit()
return inserted return inserted
@@ -441,7 +763,9 @@ def queue_urls(conn, links, source_url):
for attempt in range(1, QUEUE_INSERT_MAX_ATTEMPTS + 1): for attempt in range(1, QUEUE_INSERT_MAX_ATTEMPTS + 1):
try: try:
inserted = _insert_queue_rows(conn, rows) inserted = safe_commit(
conn, lambda: _insert_queue_rows(conn, rows)
)
if attempt > 1: if attempt > 1:
logging.info( logging.info(
"[QUEUE] Insert succeeded on retry %d/%d.", "[QUEUE] Insert succeeded on retry %d/%d.",
@@ -1222,6 +1546,10 @@ def parse_html(html, base_url):
# ============================================================ # ============================================================
def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms): def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
"""
Store/refresh one page. Writes only - commit happens in
safe_commit().
"""
# Sanitize everything that came from the web before it reaches # Sanitize everything that came from the web before it reaches
# PostgreSQL. NUL bytes are legal in HTML but illegal in text columns. # PostgreSQL. NUL bytes are legal in HTML but illegal in text columns.
url = clean_text(url) url = clean_text(url)
@@ -1292,8 +1620,6 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
crawl_ms, crawl_ms,
)) ))
conn.commit()
# ============================================================ # ============================================================
# WORKER # WORKER
@@ -1310,17 +1636,42 @@ def worker(worker_id):
crawled = 0 crawled = 0
failed = 0 failed = 0
last_stats = time.monotonic() last_stats = time.monotonic()
current_queue_id = None
update_requested = False
try: try:
conn = db_connect() conn = db_connect()
# Remember the generation this instance started with. Read ONCE
# per process: five crawler instances all remember their own
# generation (here: the same one, until a deployment bump).
update_signal.generation = get_current_generation(conn)
logging.info( logging.info(
"Started. DB=%s:%s/%s", "Started. DB=%s:%s/%s version=%d generation=%d",
DB_HOST, DB_PORT, DB_NAME DB_HOST, DB_PORT, DB_NAME,
CRAWLER_VERSION, update_signal.generation,
) )
# Regular update check OUTSIDE the transactions (own short-lived
# connection, daemon thread). It only sets update_signal.pending;
# the exit itself happens at the next safe commit point, never
# in the middle of a transaction.
checker_thread = threading.Thread(
target=periodic_update_check,
name=f"{process_name}-update-check",
daemon=True,
)
checker_thread.start()
while not STOP.is_set(): while not STOP.is_set():
item = get_next_queue_item(conn) # Safe point between page cycles: if the periodic check
# noticed a new generation, stop before starting new work.
if update_signal.pending:
update_requested = True
break
item = safe_commit(conn, lambda: get_next_queue_item(conn))
if not item: if not item:
logging.info("Queue empty. Worker waiting...") logging.info("Queue empty. Worker waiting...")
@@ -1328,6 +1679,7 @@ def worker(worker_id):
continue continue
queue_id, url, source_url = item queue_id, url, source_url = item
current_queue_id = queue_id
logging.info("=" * 70) logging.info("=" * 70)
logging.info("[CRAWL] %s", url) logging.info("[CRAWL] %s", url)
@@ -1339,7 +1691,12 @@ def worker(worker_id):
if error: if error:
logging.info("[HTTP ] %s -> %s", url, error) logging.info("[HTTP ] %s -> %s", url, error)
finish_queue_item(conn, queue_id, success=False) safe_commit(
conn,
lambda: finish_queue_item(
conn, queue_id, success=False
),
)
failed += 1 failed += 1
continue continue
@@ -1348,7 +1705,12 @@ def worker(worker_id):
# A redirect normally arrives as the final URL in urllib. # A redirect normally arrives as the final URL in urllib.
# The requested URL remains the canonical queue identity. # The requested URL remains the canonical queue identity.
if html is None: if html is None:
finish_queue_item(conn, queue_id, success=False) safe_commit(
conn,
lambda: finish_queue_item(
conn, queue_id, success=False
),
)
failed += 1 failed += 1
continue continue
@@ -1356,7 +1718,12 @@ def worker(worker_id):
parsed = parse_html(html, url) parsed = parse_html(html, url)
except Exception as exc: except Exception as exc:
logging.exception("[PARSE] %s: %s", url, exc) logging.exception("[PARSE] %s: %s", url, exc)
finish_queue_item(conn, queue_id, success=False) safe_commit(
conn,
lambda: finish_queue_item(
conn, queue_id, success=False
),
)
failed += 1 failed += 1
continue continue
@@ -1367,7 +1734,9 @@ def worker(worker_id):
logging.info("[HEADS ] %d", len(parsed["headings"])) logging.info("[HEADS ] %d", len(parsed["headings"]))
logging.info("[LINKS ] %d gefunden", len(parsed["links"])) logging.info("[LINKS ] %d gefunden", len(parsed["links"]))
save_page( safe_commit(
conn,
lambda: save_page(
conn=conn, conn=conn,
url=url, url=url,
source_url=source_url, source_url=source_url,
@@ -1375,6 +1744,7 @@ def worker(worker_id):
content_type="text/html", content_type="text/html",
parsed=parsed, parsed=parsed,
crawl_ms=crawl_ms, crawl_ms=crawl_ms,
),
) )
inserted = queue_urls( inserted = queue_urls(
@@ -1390,9 +1760,13 @@ def worker(worker_id):
inserted, inserted,
) )
finish_queue_item(conn, queue_id, success=True) safe_commit(
conn,
lambda: finish_queue_item(conn, queue_id, success=True),
)
crawled += 1 crawled += 1
current_queue_id = None
# Periodic stats; not on every page to avoid unnecessary DB load. # Periodic stats; not on every page to avoid unnecessary DB load.
if time.monotonic() - last_stats >= 30: if time.monotonic() - last_stats >= 30:
@@ -1403,15 +1777,58 @@ def worker(worker_id):
) )
queued = cur.fetchone()[0] queued = cur.fetchone()[0]
write_stats(conn, crawled, queued, failed) safe_commit(
conn,
lambda: write_stats(conn, crawled, queued, failed),
)
last_stats = time.monotonic() last_stats = time.monotonic()
except UpdateRequired:
raise
except Exception: except Exception:
conn.rollback() conn.rollback()
except UpdateRequired:
# A safe point (safe_commit) noticed the new generation and
# rolled back the open transaction BEFORE raising. Nothing was
# committed half-done.
update_requested = True
# Best effort: give the claimed item back to the queue so the
# next version can crawl it immediately. (Should this fail,
# requeue_stale_items() recovers it later anyway.) This commit
# is wanted shutdown bookkeeping, not crawled content.
try:
if current_queue_id is not None:
with conn.cursor() as cur:
cur.execute(
"UPDATE crawl_queue "
"SET status = 'pending' "
"WHERE id = %s AND status = 'processing'",
(current_queue_id,),
)
conn.commit()
logging.info(
"[UPDATE] Queue item %d requeued for the next version.",
current_queue_id,
)
except Exception:
try:
conn.rollback()
except Exception:
pass
logging.warning(
"[UPDATE] %s stops for update (started with generation %s, "
"version %d).",
process_name,
update_signal.generation,
CRAWLER_VERSION,
)
except Exception: except Exception:
logging.exception("Worker crashed.") logging.exception("Worker crashed.")
raise raise
finally: finally:
update_signal.stop_checker = True
if conn is not None: if conn is not None:
conn.close() conn.close()
@@ -1420,6 +1837,10 @@ def worker(worker_id):
crawled, failed crawled, failed
) )
if update_requested:
# Reserved exit code, exclusively for "update required".
sys.exit(EXIT_UPDATE_REQUIRED)
# ============================================================ # ============================================================
# STARTUP # STARTUP
@@ -1434,12 +1855,16 @@ def main():
print(f"Delay: {DEFAULT_DELAY:.1f} Sekunden") print(f"Delay: {DEFAULT_DELAY:.1f} Sekunden")
print(f"Robots-Cache: {ROBOTS_CACHE_SECONDS // 3600} Stunden") print(f"Robots-Cache: {ROBOTS_CACHE_SECONDS // 3600} Stunden")
print(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}") print(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
print(f"Version: {CRAWLER_VERSION}")
print() print()
conn = db_connect() conn = db_connect()
ensure_deployment_state_table(conn)
ensure_stats_table(conn) ensure_stats_table(conn)
requeue_stale_items(conn) safe_commit(conn)
safe_commit(conn, lambda: requeue_stale_items(conn))
# Seed URL. ON CONFLICT makes restarting harmless. # Seed URL. ON CONFLICT makes restarting harmless.
queue_urls(conn, [normalize_url(START_URL)], None) queue_urls(conn, [normalize_url(START_URL)], None)
@@ -1453,18 +1878,34 @@ def main():
p.start() p.start()
workers.append(p) workers.append(p)
update_requested = False
worker_crashed = False
try: try:
while any(p.is_alive() for p in workers): while any(p.is_alive() for p in workers):
time.sleep(1) time.sleep(1)
# If a worker dies unexpectedly, stop the remaining workers.
for p in workers: for p in workers:
if p.exitcode not in (None, 0): if p.exitcode == EXIT_UPDATE_REQUIRED:
# This worker reached its safe point and wants the
# update. Stop the remaining workers as well (they
# also exit at their own safe points, never
# mid-transaction) and propagate exit code 42.
if not update_requested:
logging.warning(
"[UPDATE] Worker %s requests update; the "
"other workers stop at their safe points.",
p.name,
)
update_requested = True
STOP.set()
elif p.exitcode not in (None, 0):
logging.error( logging.error(
"Worker %s exited with code %s.", "Worker %s exited with code %s.",
p.name, p.name,
p.exitcode, p.exitcode,
) )
worker_crashed = True
STOP.set() STOP.set()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -1481,9 +1922,237 @@ def main():
p.terminate() p.terminate()
p.join() p.join()
if update_requested:
# Reserved exit code: "update required". Only the launcher may
# interpret this code; normal errors use EXIT_ERROR (1).
print("Crawler stopped: update required.")
sys.exit(EXIT_UPDATE_REQUIRED)
if worker_crashed:
# A normal error must NOT look like a normal end (exit 0) or an
# update (42): the launcher restarts on this code unchanged.
print("Crawler stopped: worker error.")
sys.exit(EXIT_ERROR)
print("Crawler stopped.") print("Crawler stopped.")
# ============================================================
# CLI (version / deployment / update checks)
# ============================================================
def build_arg_parser():
parser = argparse.ArgumentParser(
prog="crawler.py",
description="JonasSearch PostgreSQL crawler with "
"generation-based rolling updates.",
)
parser.add_argument(
"--version",
action="store_true",
help="print the crawler version (just the number) and exit",
)
parser.add_argument(
"--self-update",
type=int,
metavar="VERSION",
help="verify a staged crawler release in "
"%s/<VERSION>/crawler.py without activating it "
"(exit 0 = startable, 1 = broken/missing)" % CRAWLER_RELEASES_DIR,
)
parser.add_argument(
"--update-deployment",
type=int,
metavar="VERSION",
help="bump the deployment generation and set required_version "
"to VERSION. Run ONLY after the new version has been "
"provisioned and verified - from this moment all running "
"crawlers stop at their next safe commit point.",
)
parser.add_argument(
"--init-deployment",
action="store_true",
help="create deployment_state if needed and seed the initial "
"row (id=1, generation 1, required_version = this "
"crawler's version)",
)
return parser
def cmd_version():
# Deliberately machine-readable: the launcher parses this output.
print(CRAWLER_VERSION)
return EXIT_OK
def _basic_self_check():
"""
Minimal smoke test used by --self-update when no releases directory
exists yet: this file must at least compile.
"""
try:
with open(__file__, "r", encoding="utf-8") as fh:
compile(fh.read(), __file__, "exec")
except Exception as exc:
logging.error("[UPDATE] Self-check failed: %s", exc)
return EXIT_ERROR
print("[UPDATE] Basic self-check passed.")
return EXIT_OK
def cmd_self_update(version):
"""
Verify that a staged release for `version` exists and is startable
- WITHOUT activating it. The launcher runs this with the CURRENT
(old) interpreter before swapping anything, so a broken release can
never become active (fallback guarantee).
Checks:
1. releases/<VERSION>/crawler.py exists,
2. `python3 releases/<VERSION>/crawler.py --version` prints exactly
`version` and exits with EXIT_OK (imports, syntax and the
version constant of the new file are all checked).
"""
release_file = CRAWLER_RELEASES_DIR / str(version) / "crawler.py"
if not CRAWLER_RELEASES_DIR.is_dir():
logging.warning(
"[UPDATE] No releases directory (%s); running the basic "
"self-check only.",
CRAWLER_RELEASES_DIR,
)
return _basic_self_check()
if not release_file.is_file():
logging.error(
"[UPDATE] Staged release missing: %s", release_file
)
return EXIT_ERROR
print(f"[UPDATE] Checking staged release: {release_file}")
try:
result = subprocess.run(
[sys.executable, str(release_file), "--version"],
capture_output=True,
text=True,
timeout=60,
)
except Exception as exc:
logging.error("[UPDATE] Release check could not run: %s", exc)
return EXIT_ERROR
printed = (result.stdout or "").strip()
if result.returncode != EXIT_OK or printed != str(version):
logging.error(
"[UPDATE] Staged release check FAILED (exit code %d, "
"version output %r, stderr: %s). The release is NOT "
"activated; the old version stays in place.",
result.returncode,
printed,
(result.stderr or "").strip()[-500:],
)
return EXIT_ERROR
print(f"[UPDATE] Release {version} verified and startable.")
return EXIT_OK
def cmd_update_deployment(required_version):
"""
The global switch point: atomically raise the deployment generation
and set the required crawler version in ONE statement.
Run only AFTER the new version has been provisioned and verified on
the machines (see --self-update). From this moment every running
old crawler sees the new generation at its next safe commit point,
rolls its open transaction back and exits with EXIT_UPDATE_REQUIRED
- no process is ever killed mid-transaction.
"""
conn = None
try:
conn = db_connect()
ensure_deployment_state_table(conn)
with conn.cursor() as cur:
cur.execute(
"""
UPDATE deployment_state
SET generation = generation + 1,
required_version = %s
WHERE id = 1
RETURNING generation, required_version
""",
(required_version,),
)
row = cur.fetchone()
if row is None:
logging.error(
"deployment_state row (id = 1) missing; nothing updated."
)
conn.rollback()
return EXIT_ERROR
conn.commit()
generation, activated_version = row
print(
f"[UPDATE] Deployment generation -> {generation}, "
f"required_version -> {activated_version}. Running crawlers "
"stop at their next safe commit point with exit code "
f"{EXIT_UPDATE_REQUIRED}."
)
return EXIT_OK
except Exception:
logging.exception("Deployment update failed.")
if conn is not None:
try:
conn.rollback()
except Exception:
pass
return EXIT_ERROR
finally:
if conn is not None:
conn.close()
def cmd_init_deployment():
"""
One-time initialization of deployment_state: create the table and
the single current-state row (id = 1) with generation 1 and the
currently running crawler version. Restarting is harmless.
"""
conn = None
try:
conn = db_connect()
ensure_deployment_state_table(conn)
row = _fetch_generation_row(conn)
generation = int(row[0]) if row else 1
print(
f"[UPDATE] deployment_state ready: generation {generation}, "
f"required_version {CRAWLER_VERSION}."
)
return EXIT_OK
except Exception:
logging.exception("Initializing deployment_state failed.")
return EXIT_ERROR
finally:
if conn is not None:
conn.close()
def main_cli(argv=None):
args = build_arg_parser().parse_args(argv)
if args.version:
return cmd_version()
if args.self_update is not None:
return cmd_self_update(args.self_update)
if args.update_deployment is not None:
return cmd_update_deployment(args.update_deployment)
if args.init_deployment:
return cmd_init_deployment()
main()
return EXIT_OK
if __name__ == "__main__": if __name__ == "__main__":
mp.set_start_method("spawn", force=True) mp.set_start_method("spawn", force=True)
main() sys.exit(main_cli())
+620
View File
@@ -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())
+749 -158
View File
File diff suppressed because it is too large Load Diff