programming:deployment
Differences
This shows you the differences between two versions of the page.
| Next revision | Previous revision | ||
| programming:deployment [2026/08/28 20:26] – New page: operating a week-long crawl - duration, checkpoints, retries, monitoring, cost, and recovering from an interruption. Corpus-backed from 1,120 crawled papers. Authored by Claude. karel.kubicek.claude | programming:deployment [2026/08/28 21:04] (current) – Review round 2 (generic): claim() now checks it won the row (SELECT-then-UPDATE was handing one URL to two workers) and the queue shuffles deterministically at seed time; queue probe drops 'redis' (34 to 18, 16 matched only on a data store); infrastructur karel.kubicek.claude | ||
|---|---|---|---|
| Line 3: | Line 3: | ||
| [[Programming: | [[Programming: | ||
| - | This is the " | + | This is the " |
| <WRAP important> | <WRAP important> | ||
| - | **Long runs are normal; saying how you ran them is not.** Of the **1,120 crawled papers** in this corpus, 230 give a live-crawl start and end date at day precision. Reading each one's own sentence, **182** of those spans are machine time rather than a content date range — and **138 of the 182 (75.8%)** ran for more than a week, **53 (29.1%)** for more than three months. | + | **Long runs are normal; saying how you ran them is not.** Of the **1,120 crawled papers** in this corpus, 230 give a live-crawl start and end date at day precision. Reading each one's own sentence, **182** of those spans are one continuous stretch of machine time; the rest are content date ranges, disjoint episodes, or list-fetch dates. |
| - | Against the same 1,120: **13 (1.2%)** mention checkpointing or resuming a crawl, **21 (1.9%)** name a cluster orchestrator, | + | Against the same 1,120: **13 (1.2%)** mention checkpointing or resuming a crawl, **21 (1.9%)** name a cluster orchestrator, |
| </ | </ | ||
| Line 22: | Line 22: | ||
| ===== How long a crawl actually runs ===== | ===== How long a crawl actually runs ===== | ||
| - | Population: crawled papers with at least one '' | + | Population: crawled papers with at least one '' |
| The raw span is not the answer, because the extraction' | The raw span is not the answer, because the extraction' | ||
| Line 69: | Line 69: | ||
| ^ Probe ^ Papers ^ Share ^ | ^ Probe ^ Papers ^ Share ^ | ||
| | '' | | '' | ||
| - | | a task or job queue by name (Celery, RabbitMQ, Redis, Kafka, SQS, ...) | 34 | 3.0% | | + | | a task or job queue by name (Celery, RabbitMQ, Kafka, SQS, ...) | 18 | 1.6% | |
| | a cluster orchestrator by name (Kubernetes, | | a cluster orchestrator by name (Kubernetes, | ||
| | a monitoring or logging stack by name (Prometheus, | | a monitoring or logging stack by name (Prometheus, | ||
| Line 85: | Line 85: | ||
| The thing a week-long crawl must not lose is not the pages — you can fetch those again — it is **the record of what happened to every URL you meant to visit**. A crawl that resumes from "the files I already have" quietly redefines its own denominator: | The thing a week-long crawl must not lose is not the pages — you can fetch those again — it is **the record of what happened to every URL you meant to visit**. A crawl that resumes from "the files I already have" quietly redefines its own denominator: | ||
| - | So persist a row per URL, with exactly one terminal state, before you persist anything else. Three properties are worth getting right, and only the first is obvious: | + | So persist a row per URL, with exactly one terminal state, before you persist anything else. Four properties are worth getting right, and only the first is obvious: |
| - **Claim-then-commit with a lease and a named worker.** Mark the row '' | - **Claim-then-commit with a lease and a named worker.** Mark the row '' | ||
| - **Retryable and terminal errors are different.** NXDOMAIN will still be NXDOMAIN in four days; a timeout or a 503 often will not be. Retrying everything spends the budget on domains that no longer exist; retrying nothing throws away real data (see the next section). | - **Retryable and terminal errors are different.** NXDOMAIN will still be NXDOMAIN in four days; a timeout or a 503 often will not be. Retrying everything spends the budget on domains that no longer exist; retrying nothing throws away real data (see the next section). | ||
| + | - **A retry goes to the back of the queue, not the front.** This is one line of SQL and the obvious version gets it backwards. Ordering the pending rows by URL hands a just-failed row straight back on the next claim, because its position in the URL order has not changed and everything before it is already terminal — an immediate in-place retry against a host that refused you ten seconds ago. Ordering by attempt count first drains every first-attempt URL before any retry is taken. The published script asserts this, and the assertion fails if the '' | ||
| - **Fingerprint the seed list.** A resumed run that seeds a // | - **Fingerprint the seed list.** A resumed run that seeds a // | ||
| - | The script below is a working queue with those three properties, stdlib Python only ('' | + | The script below is a working queue with those four properties, stdlib Python only ('' |
| <file python crawl_queue.py> | <file python crawl_queue.py> | ||
| Line 112: | Line 113: | ||
| running -> pending | running -> pending | ||
| - | Three things this gets right that a dict-of-URLs in memory does not: | + | Four things this gets right that a dict-of-URLs in memory does not: |
| 1. **Claim-then-commit with a lease, and a named worker.** A worker marks a | 1. **Claim-then-commit with a lease, and a named worker.** A worker marks a | ||
| Line 129: | Line 130: | ||
| NXDOMAIN in four days; a timeout or a 503 often will not be. Retrying | NXDOMAIN in four days; a timeout or a 503 often will not be. Retrying | ||
| everything burns the budget on domains that no longer exist; retrying | everything burns the budget on domains that no longer exist; retrying | ||
| - | nothing throws away real data — Bouchet | + | nothing throws away real data — Hausladen |
| measured that nearly 40% of the sites that failed to load on the first | measured that nearly 40% of the sites that failed to load on the first | ||
| attempt loaded on the second. | attempt loaded on the second. | ||
| - | 3. **The failure classes are counted, not just the failures.** `progress()` | + | 3. **A retry goes to the back of the queue, not the front.** A URL that |
| + | returned 403 ten seconds ago will return 403 again; in ten hours, after | ||
| + | the bot-detection heuristic has forgotten you, it may not. That is one | ||
| + | line of SQL — ``ORDER BY attempts ASC, url ASC`` — and ordering by URL | ||
| + | alone silently gives you the opposite. | ||
| + | |||
| + | 4. **The failure classes are counted, not just the failures.** `progress()` | ||
| returns the per-class breakdown, which is both the monitoring signal while | returns the per-class breakdown, which is both the monitoring signal while | ||
| the crawl runs and the attrition table the methods section owes a reviewer. | the crawl runs and the attrition table the methods section owes a reviewer. | ||
| Line 158: | Line 165: | ||
| status | status | ||
| attempts | attempts | ||
| + | seq TEXT NOT NULL, | ||
| worker | worker | ||
| lease_until | lease_until | ||
| Line 164: | Line 172: | ||
| updated_at | updated_at | ||
| ); | ); | ||
| - | CREATE INDEX IF NOT EXISTS work_status ON work(status); | + | CREATE INDEX IF NOT EXISTS work_status ON work(status, attempts, seq); |
| CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL); | CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL); | ||
| """ | """ | ||
| Line 231: | Line 239: | ||
| now = self.clock() | now = self.clock() | ||
| before = self.db.execute(" | before = self.db.execute(" | ||
| + | # seq is a deterministic shuffle: sha256(digest + url). Claim order is | ||
| + | # (attempts, seq), so the crawl walks the list in an order that is | ||
| + | # reproducible from the seed but uncorrelated with rank, TLD or name. | ||
| + | # It matters because a crawl that dies halfway through a rank-ordered | ||
| + | # queue loses the tail of the ranking systematically; | ||
| + | # a shuffled queue loses a random subset, which is a far weaker claim | ||
| + | # to have to defend in a methods section. | ||
| self.db.executemany( | self.db.executemany( | ||
| - | " | + | " |
| - | [(u, now) for u in urls], | + | "VALUES(?,' |
| + | [(u, hashlib.sha256((digest + u).encode(" | ||
| ) | ) | ||
| return self.db.execute(" | return self.db.execute(" | ||
| Line 267: | Line 283: | ||
| def claim(self, worker: str = " | def claim(self, worker: str = " | ||
| - | """ | + | """ |
| - | now = self.clock() | + | |
| - | row = self.db.execute( | + | Ordering is ``attempts ASC, url ASC``, not ``url ASC``. A row that |
| - | " | + | failed retryably keeps its position in the URL order, so ordering by |
| - | ).fetchone() | + | URL alone hands it straight back on the next claim — an immediate |
| - | if row is None: | + | in-place retry, which is the worst time to try a host that just |
| - | return None | + | refused you. Ordering by attempts first drains every first-attempt URL |
| - | url, attempts = row | + | before any retry is taken. |
| - | self.db.execute( | + | |
| - | " | + | |
| - | "WHERE url=? AND status=' | + | |
| - | (attempts + 1, worker, now + self.lease_seconds, | + | row = self.db.execute( |
| - | ) | + | " |
| - | return url, attempts + 1 | + | "ORDER BY attempts ASC, seq ASC LIMIT 1" |
| + | ).fetchone() | ||
| + | if row is None: | ||
| + | return None | ||
| + | url, attempts = row | ||
| + | cur = self.db.execute( | ||
| + | " | ||
| + | "updated_at=? | ||
| + | (attempts + 1, worker, now + self.lease_seconds, | ||
| + | ) | ||
| + | # SELECT-then-UPDATE is not atomic. Another worker can win the row | ||
| + | # between the two statements, and then this UPDATE matches zero | ||
| + | # rows. Returning the URL anyway would hand two workers the same | ||
| + | # site and let the second one overwrite the first' | ||
| + | # exact double-count the lease exists to prevent. Loop instead. | ||
| + | if cur.rowcount == 1: | ||
| + | | ||
| # -- outcomes --------------------------------------------------------- | # -- outcomes --------------------------------------------------------- | ||
| Line 393: | Line 425: | ||
| return visit | return visit | ||
| + | |||
| + | |||
| + | class _FakeCursor: | ||
| + | """ | ||
| + | |||
| + | def __init__(self, | ||
| + | self._row = row | ||
| + | |||
| + | def fetchone(self): | ||
| + | return self._row | ||
| + | |||
| + | |||
| + | class _RaceOnce: | ||
| + | """ | ||
| + | |||
| + | Used only by the demo. It passes everything through, except that the first | ||
| + | time claim() runs its SELECT it hands the row to a " | ||
| + | returning it — reproducing, | ||
| + | an unchecked SELECT-then-UPDATE hand one URL to two workers. | ||
| + | """ | ||
| + | |||
| + | def __init__(self, | ||
| + | self.db = db | ||
| + | self.armed = True | ||
| + | self.stolen = None | ||
| + | |||
| + | def execute(self, | ||
| + | if self.armed and sql.startswith(" | ||
| + | self.armed = False | ||
| + | row = self.db.execute(sql, | ||
| + | if row is not None: | ||
| + | self.stolen = row[0] | ||
| + | self.db.execute( | ||
| + | " | ||
| + | (row[0],), | ||
| + | ) | ||
| + | return _FakeCursor(row) | ||
| + | return self.db.execute(sql, | ||
| + | |||
| + | def executemany(self, | ||
| + | return self.db.executemany(sql, | ||
| + | |||
| + | def executescript(self, | ||
| + | return self.db.executescript(sql) | ||
| Line 436: | Line 512: | ||
| print(f" | print(f" | ||
| - | print(" | + | print(" |
| + | # The check in section C only shows that a FRESH lease is not reclaimed. | ||
| + | # A reclaim_expired() that did nothing at all would pass that. Drive a | ||
| + | # movable clock past the deadline and assert the row really comes back. | ||
| + | lease_db = os.path.join(tmp, | ||
| + | fake = {" | ||
| + | lq = CrawlQueue(lease_db, | ||
| + | lq.seed(sites[: | ||
| + | lq.claim(" | ||
| + | before = lq.reclaim_expired() | ||
| + | fake[" | ||
| + | after = lq.reclaim_expired() | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | print(" | ||
| + | # site007 fails retryably on attempts 1 and 2. With ORDER BY url it would | ||
| + | # be handed straight back; with ORDER BY attempts, url it must wait for | ||
| + | # every first-attempt URL. | ||
| + | order_db = os.path.join(tmp, | ||
| + | oq = CrawlQueue(order_db) | ||
| + | small = demo_sites(20) | ||
| + | oq.seed(small) | ||
| + | visit = make_visit() | ||
| + | order = [] | ||
| + | while True: | ||
| + | claimed = oq.claim(" | ||
| + | if claimed is None: | ||
| + | break | ||
| + | url, attempt = claimed | ||
| + | order.append((url.rstrip("/" | ||
| + | try: | ||
| + | oq.complete(url, | ||
| + | except Exception as exc: | ||
| + | oq.record_error(url, | ||
| + | first_pass = [u for u, a in order if a == 1] | ||
| + | retries = [i for i, (u, a) in enumerate(order) if a > 1] | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | print(" | ||
| + | # SELECT-then-UPDATE is not atomic, and the window between them is the only | ||
| + | # place two workers can be handed the same URL. Marking the row running | ||
| + | # BEFORE claim() runs does not test that window — the SELECT simply skips | ||
| + | # it. _RaceOnce steals the row in the window itself: it lets the SELECT | ||
| + | # return normally and then marks that row running behind claim()' | ||
| + | # so claim()' | ||
| + | # claim() returns the stolen URL and two workers crawl the same site. | ||
| + | race_db = os.path.join(tmp, | ||
| + | rq = CrawlQueue(race_db) | ||
| + | rq.seed(sites[: | ||
| + | rq.db = _RaceOnce(rq.db) | ||
| + | got = rq.claim(" | ||
| + | stolen = rq.db.stolen | ||
| + | print(f" | ||
| + | print(f" | ||
| + | race_ok = got is not None and got[0] != stolen | ||
| + | |||
| + | print(" | ||
| + | order_a = [u for u, in oq.db.execute( | ||
| + | " | ||
| + | rerun_db = os.path.join(tmp, | ||
| + | rq2 = CrawlQueue(rerun_db) | ||
| + | rq2.seed(small) | ||
| + | order_b = [u for u, in rq2.db.execute( | ||
| + | " | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | print(" | ||
| try: | try: | ||
| resumed.seed(sites + [" | resumed.seed(sites + [" | ||
| Line 444: | Line 590: | ||
| print(f" | print(f" | ||
| - | print(" | + | print(" |
| ok = True | ok = True | ||
| checks = [ | checks = [ | ||
| Line 455: | Line 601: | ||
| ("the lease alone did not free the stranded row", by_lease == 0), | ("the lease alone did not free the stranded row", by_lease == 0), | ||
| (" | (" | ||
| + | ("a live lease is not reclaimed", | ||
| + | ("an expired lease IS reclaimed", | ||
| + | (" | ||
| + | and (not retries or retries[0] == len(small))), | ||
| + | (" | ||
| + | (" | ||
| + | (" | ||
| (" | (" | ||
| ] | ] | ||
| Line 489: | Line 642: | ||
| === A. reference run, no crash (200 URLs) === | === A. reference run, no crash (200 URLs) === | ||
| seeded 200 rows, seed sha256 49ddaed11cb3d529... | seeded 200 rows, seed sha256 49ddaed11cb3d529... | ||
| - | [worker] total=200 done=51 failed=21 running=0 pending=128 | http_404=7, nxdomain=7, timeout=7 | + | [worker] total=200 done=61 failed=21 running=0 pending=118 | http_404=9, nxdomain=12 |
| - | [worker] total=200 done=102 failed=42 running=0 pending=56 | http_404=14, nxdomain=14, timeout=14 | + | [worker] total=200 done=120 failed=40 running=0 pending=40 | http_404=20, nxdomain=20 |
| visits attempted: 280 | visits attempted: 280 | ||
| final: total=200 done=140 failed=60 running=0 pending=0 | http_404=20, | final: total=200 done=140 failed=60 running=0 pending=0 | http_404=20, | ||
| === B. same population, machine dies on the 87th visit === | === B. same population, machine dies on the 87th visit === | ||
| - | killed while visiting https://site062.example/ | + | killed while visiting https://site013.example/ |
| - | state on disk: total=200 done=44 failed=18 running=1 pending=137 | http_404=6, nxdomain=6, timeout=6 | + | state on disk: total=200 done=51 failed=19 running=1 pending=129 | http_404=7, nxdomain=12 |
| rows left in ' | rows left in ' | ||
| Line 503: | Line 656: | ||
| reclaim_expired() reclaimed 0 row(s) -- the 300s lease has not run out yet | reclaim_expired() reclaimed 0 row(s) -- the 300s lease has not run out yet | ||
| reclaim_worker(' | reclaim_worker(' | ||
| - | [worker] total=200 done=95 failed=39 running=0 pending=66 | http_404=13, nxdomain=13, timeout=13 | + | [worker] total=200 done=113 failed=36 running=0 pending=51 | http_404=17, nxdomain=19 |
| visits attempted after resume: 194 | visits attempted after resume: 194 | ||
| final: total=200 done=140 failed=60 running=0 pending=0 | http_404=20, | final: total=200 done=140 failed=60 running=0 pending=0 | http_404=20, | ||
| - | === D. the seed guard === | + | === D. does reclaim_expired() actually reclaim an expired lease? === |
| + | lease still live -> reclaim_expired() = 0 | ||
| + | clock +301s -> reclaim_expired() = 1 | ||
| + | |||
| + | === E. does a retry go to the back of the queue? === | ||
| + | claim order (site, attempt): [(' | ||
| + | first-attempt claims: 20; first retry at position 20 | ||
| + | |||
| + | === F. does claim() refuse a row it did not win? === | ||
| + | a rival took this row inside the window: https:// | ||
| + | claim() returned: | ||
| + | |||
| + | === G. is the claim order shuffled, and reproducible? | ||
| + | seed order: | ||
| + | claim order: | ||
| + | same list, new database, same order: True | ||
| + | |||
| + | === H. the seed guard === | ||
| refused, as it should: seed list changed since this queue was created (stored 49ddaed11cb3..., | refused, as it should: seed list changed since this queue was created (stored 49ddaed11cb3..., | ||
| - | === E. checks === | + | === I. checks === |
| ok same successful set | ok same successful set | ||
| ok same failure set | ok same failure set | ||
| Line 519: | Line 689: | ||
| ok the lease alone did not free the stranded row | ok the lease alone did not free the stranded row | ||
| ok | ok | ||
| + | ok a live lease is not reclaimed | ||
| + | ok an expired lease IS reclaimed | ||
| + | ok every first attempt precedes every retry | ||
| + | ok | ||
| + | ok claim order is not the seed order | ||
| + | ok claim order is reproducible from the seed | ||
| ok | ok | ||
| note clean run: 280 visits. crash+resume: | note clean run: 280 visits. crash+resume: | ||
| Line 525: | Line 701: | ||
| </ | </ | ||
| - | The check that matters is '' | + | The check that matters is '' |
| ===== Retries: what to retry, and what it buys ===== | ===== Retries: what to retry, and what it buys ===== | ||
| - | Retrying once is the single highest-return operational decision on this page, and one paper measured | + | Retrying once is the single highest-return operational decision on this page, and one paper measured |
| - | What the retry is worth depends on what fraction of your list fails at all. Papers that report | + | What the retry is worth depends on what fraction of your list fails at all. Seven papers whose attrition figures were surfaced by the retry and attrition probes and then read and verified individually — **not** an exhaustive list; the attrition probe suggests of the order of 38 crawled papers |
| ^ Paper ^ Population ^ Reached ^ What the rest were ^ | ^ Paper ^ Population ^ Reached ^ What the rest were ^ | ||
| | Kumar et al., TheWebConf 2017 {[kumar2017_security]} | Alexa top 1M | 944,000 | 15K did not resolve, 13K timed out, 24K returned an HTTP error, 5K would not render | | | Kumar et al., TheWebConf 2017 {[kumar2017_security]} | Alexa top 1M | 944,000 | 15K did not resolve, 13K timed out, 24K returned an HTTP error, 5K would not render | | ||
| | Murley et al., TheWebConf 2021 {[murley2021_websocket]} | top 1M, **after one retry** | 88.1% | "7.7% of the listed domain names failed to resolve, and the remaining 4.2% had web servers which failed to respond" | | Murley et al., TheWebConf 2021 {[murley2021_websocket]} | top 1M, **after one retry** | 88.1% | "7.7% of the listed domain names failed to resolve, and the remaining 4.2% had web servers which failed to respond" | ||
| - | | Musch et al., USENIX Sec 2021 {[musch2021_debug]} | Tranco top 1M | ~846K sites | ~8% network/ | + | | Musch and Johns, USENIX Sec 2021 {[musch2021_debug]} | Tranco top 1M | ~846K sites | ~8% network/ |
| | Annamalai et al., NDSS 2024 {[annamalai2024_fpfed]} | "the 20k websites" | | Annamalai et al., NDSS 2024 {[annamalai2024_fpfed]} | "the 20k websites" | ||
| | Kieserman et al., PoPETs 2025 {[kieserman2025_tracker]} | 42,481 sites visited | 40,150 (94.51%) | inaccessible domains, bot detection, general unreliability | | | Kieserman et al., PoPETs 2025 {[kieserman2025_tracker]} | 42,481 sites visited | 40,150 (94.51%) | inaccessible domains, bot detection, general unreliability | | ||
| - | | Kuchhal and Li, IMC 2021 {[kuchhal2021_knock]} | Tranco top lists, two waves | ~90% | not broken out | | + | | Kuchhal and Li, IMC 2021 {[kuchhal2021_knock]} | Tranco top 100K domains |
| | Demir et al., PoPETs 2024 {[demir2024_bannertools]} | a Tranco sample | 29,660 (99%) | not broken out | | | Demir et al., PoPETs 2024 {[demir2024_bannertools]} | a Tranco sample | 29,660 (99%) | not broken out | | ||
| - | Two patterns to take from that table. A top-1M crawl reaches roughly | + | Two patterns to take from that table. A top-1M crawl reaches roughly |
| Retries also come in shapes other than "do it again" | Retries also come in shapes other than "do it again" | ||
| <WRAP tip> | <WRAP tip> | ||
| - | Retry at the end, not in place. A URL that 403s now will 403 in ten seconds; it may not in ten hours, from a different exit, after the bot-detection heuristic has forgotten you. A queue with a retry budget gives you this for free — the row goes back to '' | + | Retry at the end, not in place. A URL that 403s now will 403 in ten seconds; it may not in ten hours, from a different exit, after the bot-detection heuristic has forgotten you. You do not get this for free — a queue that hands out the lowest |
| </ | </ | ||
| ===== Concurrency and throughput ===== | ===== Concurrency and throughput ===== | ||
| - | **29 of 1,120 crawled papers (2.6%)** state how many browsers or workers they ran at once. That is the whole planning literature, and it is a probe result with a hand-read verdict | + | **29 of 1,120 crawled papers (2.6%)** state how many browsers or workers they ran at once. That is nearly all the published guidance there is on sizing a crawl machine, and it is a probe result with a hand-read verdict |
| + | |||
| + | Six of the 29, chosen to span the range of scale in the class — one desktop, one cloud VM, one departmental cluster, and the three papers that also give a throughput or a wall clock: | ||
| ^ Paper ^ Hardware ^ Concurrency ^ | ^ Paper ^ Hardware ^ Concurrency ^ | ||
| Line 564: | Line 742: | ||
| The one throughput figure anyone publishes is Bouhoula et al.'s: **300 websites per hour from 30 parallel OpenWPM browsers on 16 cores** {[bouhoula2024_automated]} — about 10 sites per browser-hour, | The one throughput figure anyone publishes is Bouhoula et al.'s: **300 websites per hour from 30 parallel OpenWPM browsers on 16 cores** {[bouhoula2024_automated]} — about 10 sites per browser-hour, | ||
| - | That single number is enough to plan with. A one-million-site crawl at 300 sites per machine-hour is about 3,300 machine-hours: | + | That single number is enough to plan with. A one-million-site crawl at 300 sites per machine-hour is about 3,300 machine-hours: |
| ===== Monitoring ===== | ===== Monitoring ===== | ||
| - | **16 of 1,120 crawled papers (1.4%)** name a monitoring | + | **16 of 1,120 crawled papers (1.4%)** name a monitoring |
| * **Terminal rows per hour.** Not "pages fetched" | * **Terminal rows per hour.** Not "pages fetched" | ||
| Line 576: | Line 754: | ||
| * **A canary that is not part of the crawl.** A page you control, fetched on the same schedule through the same stack. When the numbers move, it tells you whether the web changed or you did. | * **A canary that is not part of the crawl.** A page you control, fetched on the same schedule through the same stack. When the numbers move, it tells you whether the web changed or you did. | ||
| - | Two failure modes deserve naming because a dashboard of totals hides both. A crawl can produce a **complete-looking empty database** — [[Programming: | + | Two failure modes deserve naming because a dashboard of totals hides both. A crawl can produce a **complete-looking empty database** — [[Programming: |
| ===== Cost ===== | ===== Cost ===== | ||
| - | **5 of 1,120 crawled papers (0.4%) state an infrastructure bill.** A further | + | A probe for a currency figure beside a cost word in a measurement context returns |
| - | The five, in full, are the entire published record: | + | All eight, in full. The **Kind** column matters: only the first five are a machine bill, and the row that classifies a paper is the one whose sentence the probe matched, not the paper' |
| - | ^ Paper ^ What it cost ^ | + | ^ Paper ^ Kind ^ What it cost ^ |
| - | | Wondracek et al., IEEE S&P 2010 {[wondracek2010_practical]} | "The crawling service cost us $18.47" | + | | Wondracek et al., IEEE S&P 2010 {[wondracek2010_practical]} | infrastructure | Two separate outsourced crawls: |
| - | | Thomas et al., IEEE S&P 2011 {[thomas2011_design]} | An itemised monthly AWS bill: "URL aggregation 1 Extra Large $178 Feature collection 20 High-CPU Medium $882", classification $527, " | + | | Thomas et al., IEEE S&P 2011 {[thomas2011_design]} |
| - | | Englehardt and Narayanan, CCS 2016 {[englehardt2016online]} | "This virtual machine costs around $300 per month using price estimates from May 2016" — the machine that ran the 1-million-site crawl | | + | | Englehardt and Narayanan, CCS 2016 {[englehardt2016online]} |
| - | | Zhang et al., CCS 2023 {[zhang2023_under]} | "our overall purchase cost is $44.538 for one month ($40 for renting servers and $4.538 for registering domains)" | + | | Genkin et al., USENIX Sec 2022 {[genkin2022_lend]} | infrastructure | An attack evaluation, not a crawl, but a real EC2 bill: the key was extracted "at a total computational cost of less than $1.3" on a '' |
| - | | Song et al., PoPETs 2026 {[song2026_wfpllm]} | " | + | | Zhang et al., CCS 2023 {[zhang2023_under]} |
| + | | Qiu et al., USENIX Sec 2023 {[qiu2023_calpric]} | data | Crowdsourced labelling: prior work " | ||
| + | | Pu et al., IEEE S&P 2023 {[pu2023_deepfake]} | data | "We spent $586 to collect the articles from the services" | | ||
| + | | Song et al., PoPETs 2026 {[song2026_wfpllm]} | ||
| - | Since nobody | + | Eight figures spread over sixteen years and four pricing models |
| - | - **Machine-hours** = (sites × attempts) ÷ (workers per machine × sites per worker-hour). At Bouhoula et al.'s 300 sites/ | + | - **Machine-hours** = (sites × attempts) ÷ (workers per machine × sites per worker-hour). At Bouhoula et al.'s 300 sites/ |
| - **A second pass.** Budget for it. Every longitudinal design needs one and every first pass has a bug in it. | - **A second pass.** Budget for it. Every longitudinal design needs one and every first pass has a bug in it. | ||
| - **Egress and storage.** Small for HTML and headers, not small for screenshots or full HAR files ([[Programming: | - **Egress and storage.** Small for HTML and headers, not small for screenshots or full HAR files ([[Programming: | ||
| Line 600: | Line 781: | ||
| ===== The machine dies on day four ===== | ===== The machine dies on day four ===== | ||
| - | Ten papers | + | A probe for interrupted measurements returns 21 of the 1,120 crawled |
| ^ Paper ^ What happened ^ What they wrote ^ | ^ Paper ^ What happened ^ What they wrote ^ | ||
| Line 611: | Line 792: | ||
| | Lee et al., TheWebConf 2021 {[lee2021_practice]} | network outages | "There were network outages for 17 days, which are pruned out from the dataset"; | | Lee et al., TheWebConf 2021 {[lee2021_practice]} | network outages | "There were network outages for 17 days, which are pruned out from the dataset"; | ||
| | Poteat and Li, IMC 2021 {[poteat2021_securitytxt]} | a six-week gap in a 15-month series | "we had a 1.5-month measurement interruption throughout November and the first half of December, 2020" | | | Poteat and Li, IMC 2021 {[poteat2021_securitytxt]} | a six-week gap in a 15-month series | "we had a 1.5-month measurement interruption throughout November and the first half of December, 2020" | | ||
| - | | Yeung et al., TheWebConf 2023 {[yeung2023_online]} | repeated crawler crashes, per vantage point | "Our crawler crashed on March 12th in both Ukraine and Russia, and again on March 20th and April 18th in Ukraine" | + | | Yeung et al., TheWebConf 2023 {[yeung2023_online]} | repeated crawler crashes, per vantage point | "Our crawler crashed on March 12th in both Ukraine and Russia, and again on March 20th and April 18th in Ukraine |
| | Dahlberg and Pulls, USENIX Sec 2023 {[dahlberg2023_timeless]} | Tor exit downtime plus a network-wide DDoS | the dips are visible in their own published counters | | | Dahlberg and Pulls, USENIX Sec 2023 {[dahlberg2023_timeless]} | Tor exit downtime plus a network-wide DDoS | the dips are visible in their own published counters | | ||
| - | Only one paper in the corpus | + | Only one of the 21 papers that probe returns |
| **What to actually do, in order:** | **What to actually do, in order:** | ||
| - **Do not restart the crawl.** Resume it. If your queue has per-URL outcomes and a lease, the only work at risk is what was in flight, and the demo above shows the recovered run ending with the same outcome set as the uninterrupted one. If it does not, you now have to choose between a partial dataset and re-crawling a web that has moved under you — and re-crawling is a *different measurement*, | - **Do not restart the crawl.** Resume it. If your queue has per-URL outcomes and a lease, the only work at risk is what was in flight, and the demo above shows the recovered run ending with the same outcome set as the uninterrupted one. If it does not, you now have to choose between a partial dataset and re-crawling a web that has moved under you — and re-crawling is a *different measurement*, | ||
| - | - **Record the gap in the same units as your results.** Not "we had some downtime" | + | - **Record the gap in the same units as your results.** Not "we had some downtime" |
| - **Decide what the gap does to your inference, and say so.** Wang et al. quantified it as 4.5% of broadcasts missing {[wang2016_anatomy]}; | - **Decide what the gap does to your inference, and say so.** Wang et al. quantified it as 4.5% of broadcasts missing {[wang2016_anatomy]}; | ||
| - | - **Check that the gap is not correlated with what you measure.** A crawl that dies overnight loses the sites that sort late in the list; a crawl that dies on Fridays loses whatever is different about weekends. If your queue orders by rank, a mid-run failure is a systematic loss, not a random one — shuffle the queue at seed time, or say the loss was ordered. | + | - **Check that the gap is not correlated with what you measure.** A crawl that dies overnight loses the sites that sort late in the list; a crawl that dies on Fridays loses whatever is different about weekends. If your queue orders by rank, a mid-run failure is a systematic loss, not a random one — shuffle the queue at seed time, or say the loss was ordered. The script above shuffles deterministically, |
| - **Check whether the environment changed while it was down.** A restart after a package update is a new instrument. [[Design: | - **Check whether the environment changed while it was down.** A restart after a package update is a new instrument. [[Design: | ||
| - **Then decide whether to extend.** Extending the window to recover lost coverage stretches the measurement period, which is a temporal confound, not a free fix. Say which you did. | - **Then decide whether to extend.** Extending the window to recover lost coverage stretches the measurement period, which is a temporal confound, not a free fix. Say which you did. | ||
| Line 630: | Line 811: | ||
| - **The wall-clock window**, with both endpoints at day precision. Only 230 of 1,120 crawled papers manage this, and it is the cheapest sentence on the list. | - **The wall-clock window**, with both endpoints at day precision. Only 230 of 1,120 crawled papers manage this, and it is the cheapest sentence on the list. | ||
| - | - **Whether that window is machine time or content coverage.** A quarter | + | - **Whether that window is machine time or content coverage.** A fifth of the day-precision spans in this corpus |
| - | - **The population, and how much of it you reached** — the numerator, the denominator, | + | - **The population, and how much of it you reached** — the numerator, the denominator, |
| - **The retry policy**: how many attempts, which errors were terminal, and whether the retry was immediate or at the end of the queue. If a retry pass changed your numbers, say by how much {[hausladen2025_websites]}. | - **The retry policy**: how many attempts, which errors were terminal, and whether the retry was immediate or at the end of the queue. If a retry pass changed your numbers, say by how much {[hausladen2025_websites]}. | ||
| - | - **Concurrency and hardware**, in the same sentence: N browsers on M cores with K GB. 29 papers | + | - **Concurrency and hardware**, in the same sentence: N browsers on M cores with K GB. 29 papers |
| - | - **The infrastructure**: | + | - **The infrastructure**: |
| - **Any interruption, | - **Any interruption, | ||
| - | - **The cost**, if you can. Five papers in sixteen | + | - **The cost**, if you can. Across seventeen publication |
| ===== Related pages ===== | ===== Related pages ===== | ||
| Line 655: | Line 836: | ||
| Corpus figures come from '' | Corpus figures come from '' | ||
| - | Three limits worth stating plainly. **The duration figures rest on a hand classification**, | + | Three limits worth stating plainly. **The duration figures rest on a hand classification**, |
| <bibtex bibliography></ | <bibtex bibliography></ | ||
| ~~DISCUSSION~~ | ~~DISCUSSION~~ | ||
programming/deployment.1787948800.txt.gz · Last modified: by karel.kubicek.claude
