marlin

Marlin v1: technical notes

Personal homepage crawler + LM cataloguer + search. 560,183 domains done, 4 days, single operator. This is the reference doc: architecture, decisions, numbers, failure modes. No narrative.


Architecture

Four processes. Fetcher/worker/Postgres/API+web run on a local PC. Catalog inference runs on a separate rented GPU, reached only over an OpenAI-compatible HTTP API through an SSH tunnel. No public LLM port. Steward inference stays on the PC (local LM Studio) — sample volume is tiny and must not steal catalog concurrency.

Component Responsibility
Fetcher Claim pending → HTTPS then HTTP → HTML parse, no JS execution → write title/body/outbound links onto the row → mark ready.
Worker Claim ready → skip LM call if empty/challenge-page/parked → else one structured JSON completion → mark done, wipe staging text, enqueue outbound links at weighted priority.
Steward Does not touch the main queue. Samples 5, then +5, completed pages from busy apexes → local-LM verdict block/keep/unsure → auto-inserts into blocklist. Never auto-blocks an explicit allowlist (e.g. Neocities, tilde communities, universities, git hosting).
API + web Search with filters, ignore-category toggle, admin dashboard (table sizes, queue depth, category breakdown), worker saturation charts.

Staging fields (title, body text, source URL, outbound link list) live directly on the domain’s row. Wiped on successful done to avoid unbounded text growth at scale. Kept on LM failure so a retry doesn’t require refetching.

Queue claim: FOR UPDATE SKIP LOCKED, ordered by priority DESC, id ASC. Claim order is the crawl policy, there is no separate scheduler.


Schema evolution (order forced by production, not planned upfront)

  1. Base tables: domains, categories, tags, domain_tags, trigram extension for fuzzy text search.
  2. Split fetch from LM: added staging columns so GPU idle time isn’t blocked on network I/O.
  3. Added priority + outbound host list + partial indexes to support fast claims.
  4. Added ICANN “apex” (registrable domain) derivation, needed for the subdomain cap.
  5. Added nullable language/place/country columns, no backfill of existing rows.
  6. Added blocklist table + review log, seeded from a static file.
  7. Added partial btree indexes so category/language filtering stayed fast past 500k rows.

Lesson: don’t design the full schema upfront. Ship the minimum, let production load force the next migration. Nullable-with-no-backfill is fine for late-added columns; don’t block on backfilling historical rows.


Crawl policy

Priority model

Why weight instead of block

Hard-excluding a category loses recall, a “boring” site can still link to something worth indexing. Weighting demotes without discarding. Verified in production: several near-empty categories still meaningfully seeded better categories downstream.

Subdomain flood control

Sink detection (steward)

Empty / parked / non-English handling


LM / prompting

Prompt failure modes and fixes

Failure Root cause Fix
Category leaking into the summary field Model conflated the two output fields under some inputs Treat a suspiciously short/label-like summary as a structured failure, retry once
Coherent-sounding but fabricated summary on a near-blank page Model inferred content from the hostname string when body text was empty Trust order: visible body text → title → meta. Never generate from domain name alone. Empty/near-empty input skips the LM.
Category distribution skewed toward a catch-all “other” bucket Prompt listed high-frequency generic categories (ecommerce etc.) first, priming the model toward them Reordered category list in the prompt to lead with target categories (research, blog, theatre, community, etc.)
Weak signal-to-noise on category vs tags Tags accumulated meaningful signal (e.g. hundreds of theatre-tagged pages) that the category field wasn’t capturing Tags data used to detect and correct category prompt bias, treat tags as a leading indicator when tuning categories

Model comparison


Inference infrastructure

Setup Concurrency Sustained throughput Verdict
Local consumer GPU, local inference server 2 ~60-80/min Fine for prompt development, not for volume
Rented GPU, wrapper library adding distributed compute framework 32 (briefly 48) ~300/min average, spiky, occasionally throttled to ~60/min Framework overhead consumed shared CPU; not GPU-bound, do not use this stack
Same GPU class, cold restart at full concurrency 32 immediately crash on first batch Cold KV-cache + no ramp = activation memory spike, not a steady-state OOM
Rented GPU, dedicated CPU allocation, plain inference server, no distributed wrapper 32 with gradual ramp ~600/min average (not a spike) Production workhorse

Key infra decisions

Production GPU: config and raw metrics

Box: RTX PRO 4500 (Blackwell), 32GB VRAM, 16 of 16 CPUs dedicated (no shared slice).

Inference server logs at sustained load:

Launch config that reached steady state:

Config that did not work (previous rental attempt, different day, same GPU class):

Takeaway: 0.88 utilization / 8192 batched tokens is not a tuned optimum, it’s a conservative setting that survived the crash. Given KV cache sat at 3-4% in production, there’s real headroom to push utilization and batch size higher if squeezing more throughput out of the same box.

Cost

~600 summaries/min at approximately $0.34/hour rental cost. Roughly 36,000/hour, about $1 per 100,000 domains catalogued. Full 560k-domain run cost a few GPU-hours total, not a meaningful cloud bill.


Concurrency coupling

Fetcher throughput must roughly match worker/LM throughput or one side starves the other.


Symptom Cause Fix
Uncatchable process crash from an HTTP library assertion on socket teardown Known upstream issue triggered by high fetch concurrency Explicit crash guard around that specific assertion; cancel unused redirect response bodies
Sporadic duplicate-looking category rows Looked like a missing dedup step Actually Postgres deadlocks between category/tag upsert and outbound-link insert under high write concurrency, surfaced by the ORM as a generic query failure. Fixed with retry-on-deadlock.
Foreign key violation on domain completion Steward blocked (and deleted pending rows for) an apex while a worker was mid-transaction summarising a domain under that same apex Lock the row before finalizing (FOR UPDATE), abort cleanly if the row was removed mid-transaction, moved link-enqueue out of that transaction
Off-by-one context length error Model max context slightly under actual worst-case prompt + output size Recomputed and bumped context window to comfortably exceed measured worst case

Category / tag scaling problem

This is the part that does not scale as-is.


Storage / query scaling


What doesn’t scale as-is (explicit list)

  1. Free-text category/tag identity. Needs canonicalization strategy before 10x growth.
  2. No recrawl. One LM call per domain, ever. Hijacked/expired domains that change ownership after being catalogued stay wrong indefinitely. Steward prevents new bad domains from entering; it does not correct or re-examine anything already done.
  3. No JS rendering. A meaningful minority of the visually-relevant target sites (small personal sites using canvas/WebGL, heavy client-side rendering) are permanently invisible to a Cheerio-only fetcher. Any fix here changes the cost model significantly (headless rendering is not cheap at the concurrency level this system runs at).
  4. English-only heuristic stack (TLD whitelist + subdomain-language skip + language penalty) is a pragmatic pile of heuristics, not a real language-detection solution. Leaks obscure-language content through occasionally.
  5. Postgres claim/index/autovacuum behavior was fine to <1M rows. Explicitly flagged as unverified beyond that without further tuning work.
  6. Default/unclassified priority bucket. A default priority of zero groups “genuinely unknown” pages together with a long tail of low-value categories the model defaults to (hotel, generic business, expired-domain squats). This under-differentiates and lets low-value pages accumulate disproportionately in the unexamined middle of the queue. Needs a stricter “unknown” bucket or exclude unclassified from search by default.

What worked, kept as-is


Reference numbers (final state, 560,183 done)