fix limits

This commit is contained in:
2026-09-13 18:01:58 +02:00
parent 682c0f4971
commit 4315f1fdb3
3 changed files with 209 additions and 14 deletions
+31
View File
@@ -6,6 +6,37 @@ das komplette serverjonas search
- `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)
- `web/` – Statistik-Webseite
- `fix_pages_search_vector_trigger.sql` – einmalig laufen lassen: kappelt den Text,
der im DB-Trigger `pages_search_vector_update()` in `to_tsvector()` einfließt
(Postgres hartes Limit: 1.048.575 Bytes pro `tsvector`)
## Sehr große Seiten / tsvector-Limit
PostgreSQL begrenzt einen einzelnen `tsvector`-Wert hart auf 1.048.575 Bytes
(nicht konfigurierbar). Der Trigger `pages_search_vector_update()` baut diesen
aus der `content`-Spalte – Seiten mit sehr viel Text (beobachtet: ~2 MB, z. B.
`pkg.go.dev/...?tab=importedby`) haben den Insert dadurch mit
`ProgramLimitExceeded` abgebrochen und alle Worker crashen lassen.
Dagegen wirken drei Maßnahmen zusammen:
1. **DB-Trigger (empfohlen, einmalig einspielen):**
`fix_pages_search_vector_trigger.sql` ersetzt die Trigger-Funktion so, dass
nur die ersten 200.000 Zeichen in `to_tsvector()` einfließen. Der volle Text
bleibt in `content` gespeichert, nur der Suchindex ist gekürzt. Vor dem
Einspielen die eigene Funktions-Version prüfen:
`SELECT prosrc FROM pg_proc WHERE proname = 'pages_search_vector_update';`
2. **Im Crawler:** `save_page()` kappelt den Inhalt vor dem Insert zusätzlich
auf `CONTENT_INDEX_MAX_CHARS` (200.000 Zeichen) – wirkt sofort, auch ohne
Trigger-Fix. Überlange Seiten werden dann normal gespeichert.
3. **Fehlerbehandlung:** Sollte der Insert trotzdem an einem Datenbank-Limit
scheitern, fängt der Worker `ProgramLimitExceeded` ab, loggt die URL als
Warnung, rollt die Transaktion zurück, markiert den Queue-Eintrag als
`failed` (kein endloses Wiederholen) und läuft weiter, statt zu sterben.
Zusätzlich werden bekannt wertlose URLs (`pkg.go.dev/...?tab=importedby` –
Megabyte-große reine Rückverweis-Listen) über `SKIP_URL_PATTERNS` schon vor dem
Fetch übersprungen und als `done` markiert.
## Rolling Updates (Generation-Mechanismus)
+109 -1
View File
@@ -192,6 +192,20 @@ BLOCKED_PREFIXES = (
"file:",
)
# Pages with no search-index value that only burn bandwidth, CPU and
# index space: pkg.go.dev's reverse-dependency tabs are megabyte-sized
# link lists (10k+ links of pure package names). These URLs are
# skipped before fetching; the queue item is marked done so it is not
# retried endlessly.
SKIP_URL_PATTERNS = tuple(
re.compile(pattern)
for pattern in (
# Query part (not fragment), then the tab value, then only a
# fragment or the end may follow.
r"^https?://pkg\.go\.dev/[^#]*[?&]tab=importedby(#|$)",
)
)
# Keep generated pages reasonably useful for search.
MAX_TITLE_LENGTH = 1000
MAX_HEADINGS = 200
@@ -199,6 +213,15 @@ MAX_HEADING_LENGTH = 1000
MAX_TEXT_LENGTH = 2_000_000
MAX_LINKS = 10_000
# Cap for the text that feeds the full-text index. PostgreSQL
# hard-limits a single tsvector value to 1,048,575 bytes (not
# configurable), and the trigger pages_search_vector_update() builds
# the vector from the content column. Content near MAX_TEXT_LENGTH
# would overflow that limit and abort the whole INSERT with
# ProgramLimitExceeded. 200,000 characters are always safe: even with
# 4-byte UTF-8 characters this stays well below 1,048,575 bytes.
CONTENT_INDEX_MAX_CHARS = 200_000
# Maximum size of a URL (UTF-8 encoded) that may be inserted into the
# crawl queue. PostgreSQL B-tree index entries are limited to about 2704
# bytes, so extremely long URLs would abort the INSERT with
@@ -519,6 +542,11 @@ def safe_commit(conn, operation=None):
raise UpdateRequired: the caller's writes are discarded
cleanly, nothing is committed half-done.
2. Otherwise run the writes (operation) and commit them.
3. If the operation hits ProgramLimitExceeded (e.g. a tsvector
overflow - PostgreSQL hard-limits one tsvector value to
1,048,575 bytes), roll the aborted transaction back and
re-raise, so the caller can skip the work item cleanly
instead of dying on a poisoned connection.
Every crawler commit that does real work goes through here
(page saves, queue inserts, queue claim/finish, stats), so a
@@ -535,7 +563,22 @@ def safe_commit(conn, operation=None):
"new deployment generation active; update required"
)
try:
result = operation() if operation is not None else None
except psycopg2.errors.ProgramLimitExceeded:
# The failed transaction has poisoned the connection ("current
# transaction is aborted, commands ignored until end of
# transaction block"); roll it back so the connection stays
# usable, then let the caller decide (worker: skip the page,
# queue_urls: retry the batch). Without the rollback the very
# next statement on this connection would fail as well.
try:
conn.rollback()
except Exception:
logging.exception(
"Rollback after ProgramLimitExceeded failed."
)
raise
conn.commit()
return result
@@ -1396,6 +1439,15 @@ class RateLimiter:
self.last_request[host] = time.monotonic()
def is_skipped_url(url):
"""
True for URLs whose pages bring no search-index value but burn
significant crawl resources (bandwidth, parsing time, index
size). See SKIP_URL_PATTERNS.
"""
return any(pattern.match(url) for pattern in SKIP_URL_PATTERNS)
def fetch(url, limiter, robots):
parsed = urlparse(url)
@@ -1565,6 +1617,25 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
"tld": clean_text(parsed["tld"]),
}
# Cap the text that feeds the full-text index. The DB trigger
# pages_search_vector_update() builds a tsvector from the content
# column and PostgreSQL hard-limits one tsvector value to
# 1,048,575 bytes (not configurable). Pages near MAX_TEXT_LENGTH
# overflow it and abort the whole INSERT (ProgramLimitExceeded),
# which used to kill the worker. Capping here keeps oversize
# pages saveable (with a truncated index); the trigger-side cap
# (fix_pages_search_vector_trigger.sql) adds the same protection
# for every other insert path, independent of this code.
index_content = safe_parsed["content"][:CONTENT_INDEX_MAX_CHARS]
if len(index_content) < len(safe_parsed["content"]):
logging.warning(
"[SAVE ] %s: content capped for the search index "
"(%d -> %d chars).",
url,
len(safe_parsed["content"]),
len(index_content),
)
with conn.cursor() as cur:
cur.execute("""
INSERT INTO pages (
@@ -1609,7 +1680,7 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
url,
safe_parsed["title"],
safe_parsed["headings"],
safe_parsed["content"],
index_content,
safe_parsed["links"],
safe_parsed["domain"],
safe_parsed["tld"],
@@ -1685,6 +1756,20 @@ def worker(worker_id):
logging.info("[CRAWL] %s", url)
logging.info("[FROM ] %s", source_url or "-")
# Known no-value pages (e.g. pkg.go.dev "?tab=importedby":
# megabyte-sized reverse-dependency link lists) are
# skipped before fetching. Marked 'done' so they are not
# retried endlessly.
if is_skipped_url(url):
logging.info("[SKIP ] %s (no search value)", url)
safe_commit(
conn,
lambda: finish_queue_item(
conn, queue_id, success=True
),
)
continue
started = time.monotonic()
status, html, error = fetch(url, limiter, robots)
@@ -1734,6 +1819,7 @@ def worker(worker_id):
logging.info("[HEADS ] %d", len(parsed["headings"]))
logging.info("[LINKS ] %d gefunden", len(parsed["links"]))
try:
safe_commit(
conn,
lambda: save_page(
@@ -1746,6 +1832,28 @@ def worker(worker_id):
crawl_ms=crawl_ms,
),
)
except psycopg2.errors.ProgramLimitExceeded as exc:
# Defense-in-depth: save_page() caps the content before
# the insert, so this can only fire if the DB-side
# trigger overflows anyway (e.g. because
# fix_pages_search_vector_trigger.sql has not been
# applied yet). safe_commit() has already rolled the
# transaction back; mark the queue item failed so the
# page is not retried endlessly, and keep the worker
# alive.
logging.warning(
"[SAVE ] %s: not indexable, skipped (%s)",
url,
exc,
)
safe_commit(
conn,
lambda: finish_queue_item(
conn, queue_id, success=False
),
)
failed += 1
continue
inserted = queue_urls(
conn,
+56
View File
@@ -0,0 +1,56 @@
-- fix_pages_search_vector_trigger.sql
--
-- Caps the text that feeds the full-text index of the `pages` table.
--
-- Problem:
-- PostgreSQL hard-limits a single tsvector value to 1,048,575 bytes
-- (not configurable). The trigger function
-- pages_search_vector_update() built the vector from the full
-- content column, so pages with very large text content (observed:
-- ~2 MB, e.g. pkg.go.dev "?tab=importedby" pages) aborted the whole
-- INSERT with ProgramLimitExceeded ("string is too long for
-- tsvector") and crashed all crawler workers.
--
-- Fix:
-- Feed at most the first 200,000 characters of the content into
-- to_tsvector(). Even at worst-case 4 bytes per UTF-8 character this
-- stays far below the tsvector limit. The full text remains stored
-- in the content column - only the search index is truncated.
-- 200,000 characters cover the meaningful prose of essentially all
-- real pages; the oversized ones are link lists with no index value.
--
-- Run once on the jonassearch database as its owner, e.g.:
-- psql -h 127.0.0.1 -p 25432 -U jonassearch -d jonassearch \
-- -f fix_pages_search_vector_trigger.sql
--
-- Safe to run repeatedly (CREATE OR REPLACE) and safe to run while the
-- crawler is running: existing rows keep their vectors, only newly
-- INSERTed/UPDATEd rows use the capped version.
CREATE OR REPLACE FUNCTION pages_search_vector_update()
RETURNS trigger AS $$
BEGIN
-- Cap the indexed text: a tsvector value may hold at most
-- 1,048,575 bytes. left(content, 200000) can never exceed that
-- (200000 chars * max 4 bytes UTF-8 = 800,000 bytes).
NEW.search_vector :=
to_tsvector('simple', left(coalesce(NEW.content, ''), 200000));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Notes for the DBA:
-- * If your existing function uses a different text search config
-- (e.g. 'german' or 'english') or indexes additional columns
-- (title, headings), port the left(..., 200000) cap into YOUR
-- function body instead of blindly replacing it - check first with:
-- SELECT prosrc FROM pg_proc WHERE proname =
-- 'pages_search_vector_update';
-- * The trigger itself (CREATE TRIGGER pages_search_vector_update
-- BEFORE INSERT OR UPDATE ...) is unchanged; only the function body
-- above is replaced.
-- * Optional backfill of already-stored rows (this runs UPDATEs over
-- the whole table; run during low traffic):
-- UPDATE pages SET content = content
-- WHERE search_vector IS NULL
-- OR octet_length(search_vector::text) > 1048575;