add launcher
This commit is contained in:
@@ -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/)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -178,3 +178,8 @@ cython_debug/
|
|||||||
|
|
||||||
|
|
||||||
web/page_counts.json
|
web/page_counts.json
|
||||||
|
|
||||||
|
# Crawler rolling updates
|
||||||
|
releases/
|
||||||
|
.active-crawler-version
|
||||||
|
crawler.py.bak-*
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
+706
-37
@@ -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,14 +1734,17 @@ 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=conn,
|
conn,
|
||||||
url=url,
|
lambda: save_page(
|
||||||
source_url=source_url,
|
conn=conn,
|
||||||
status=status,
|
url=url,
|
||||||
content_type="text/html",
|
source_url=source_url,
|
||||||
parsed=parsed,
|
status=status,
|
||||||
crawl_ms=crawl_ms,
|
content_type="text/html",
|
||||||
|
parsed=parsed,
|
||||||
|
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
@@ -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())
|
||||||
+762
-171
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user