A long-running poller that archives new posts and comments from specific
subreddits into the MILA S3 data lake. It queries the Reddit API with
httpx on a cron-like 60-second cycle
(aligned with Reddit's 100 QPM OAuth rate limit), authenticates via OAuth2,
and keeps a small local state file so it can resume cleanly after restarts.
Data lands in two separate S3 datasets:
reddit-posts— one row per post (t3_...), withtitle,selftext,url,num_comments,upvote_ratio, …reddit-comments— one row per comment (t1_...), withbody,link_id,parent_id, …
The eventual join is comments.link_id == posts.name (both columns are
Reddit fullnames, and name/id are also the row dedup keys).
Create an app at https://www.reddit.com/prefs/apps. A "personal use script" app is all you need:
- Fill
REDDIT_CLIENT_IDandREDDIT_CLIENT_SECRETin.env. - Done — the script authenticates itself automatically via the
client_credentialsgrant (an anonymous, application-only token, valid 24 h, re-requested on each process start). No login, no redirect URI, no username/password.
If you only have a "web" app (redirect URI required), see Web-app login (alternative) below.
cp .env.example .env
# fill in REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT, and the S3_* variables.env uses plain KEY=VALUE lines — no export keywords. The script loads
it automatically at startup (real environment variables take precedence), so
there is nothing to source. REDDIT_USER_AGENT must be descriptive, e.g.
linux:reddit-firehose:v0.1.0 (by /u/your_username).
uv sync --group devFor a "web" app, set REDDIT_REDIRECT_URI in .env (register the same URI
in the app settings, e.g. http://localhost:8080). On first run the script
prints an authorization URL. Open it, log in, click Allow. The browser
then redirects to http://localhost:8080/?state=...&code=... — the page
fails to load, which is expected. Copy the full URL from the browser's
address bar and paste it back into the terminal. The refresh token is stored
in the state file (mode 0600), so all subsequent runs are fully
non-interactive. (Headless alternative: run the flow once anywhere and copy
the state file, or set REDDIT_REFRESH_TOKEN in .env.)
uv run python main.py --subreddits Quebec,Montreal [options]
| Option | Default | Meaning |
|---|---|---|
--subreddits |
(required) | comma-separated subreddits to monitor |
--interval |
60 |
seconds between poll cycles (aligns with 100 QPM) |
--dataset-prefix |
reddit |
datasets are {prefix}-posts and {prefix}-comments |
--state-file |
reddit_firehose_state.json |
local progress + OAuth token file |
--max-pages |
25 |
listing pages per subreddit × endpoint per cycle |
--limit |
100 |
items per page (Reddit max) |
--batch-seconds |
3600 |
length of one S3 batch (reddit-firehose-YYYYMMDD-HH) |
--max-cycles |
(unlimited) | exit after N cycles (smoke tests) |
--dry-run |
off | print JSON records to stdout instead of uploading to S3 |
--skip-auth |
off | unauthenticated requests (dev only, much lower limits) |
--api-base |
https://oauth.reddit.com |
Reddit API base (authenticated calls must use oauth.reddit.com — api.reddit.com rejects app-only tokens) |
--no-backfill |
off | first run: start from "now" instead of capturing page 1 |
-
Every cycle, each subreddit's
/new(posts) and/comments(comments) listings are polled (limit=100, paginated viaafterup to--max-pages). -
Progress per (subreddit, endpoint) is a
last_seen_utccursor plus the fullnames seen at the cursor's boundary second (Reddit timestamps are second-quantized, so ties are common). The cursor only advances after a stream polls successfully, so a failure never loses items. -
The process runs forever, splitting the stream into hourly batches uploaded by
s3-data-toolas JSONL chunks + run manifests:{S3_PREFIX}/{dataset}/reddit-firehose-YYYYMMDD-HH/{run_id}_chunk_00000.jsonl {S3_PREFIX}/{dataset}/reddit-firehose-YYYYMMDD-HH/{run_id}.manifest.json -
Deduplication on the
namecolumn happens at the data-lake-pipeline merge/cleanup step, so the rare re-emitted boundary row (e.g. after a crash mid-cycle) is absorbed there.
The client paces itself to a configurable QPM budget (--qpm-budget, default
90 — under Reddit's 100 QPM OAuth limit), honors x-ratelimit-* headers and
Retry-After, and retries 429/5xx/network errors with exponential backoff.
Startup warns if 2 × subreddits × max_pages could exceed the budget.
The firehose only ever ingests items newer than its stream start, so comments
referencing posts (link_id, t3_...) or parent comments
(parent_id, t1_...) created before collection began reference records
missing from the datasets forever. backfill.py closes that gap: a
long-running daemon (systemd, same supervised-process style as main.py)
that runs one iteration per day:
- Scans comments collected in the last
--window-hours(default 48 h, batch-name windowed — overlapping days make missed runs self-healing). - Finds referenced fullnames missing from the datasets, where membership
comes from a small prefill index maintained in S3
(
{prefix}/_backfill/names-{dataset}.parquet+ manifest, refreshed incrementally from newly merged batches) plus the names found in unmerged JSONL chunks. The S3 store is the single source of truth; the full datasets are never re-scanned daily. - Fetches the missing records via Reddit
/api/info(≤100 fullnames per request, under the same QPM budget as the firehose), following newly revealed references to a fixed point (bounded by--max-fetch). - Uploads posts to
{prefix}-postsand comments to{prefix}-comments, both in the daily batchreddit-firehose-backfill-YYYYMMDD, merges them, updates the prefill index, and writes a completion marker ({prefix}/_backfill/reddit-firehose-backfill-YYYY-MM-DD.done).
Concurrency safety uses the WSS MUTEX service (cf-workers-mutex) through
s3-data-tool's S3Lock: the per-day lock is a TTL lock file in S3 whose
check-and-set is guarded by the mutex websocket, held per iteration (renewed
while work runs, never held across the sleep). A second instance of the
backfill simply skips its iteration while the lock is held.
uv run python backfill.py [options]
| Option | Default | Meaning |
|---|---|---|
--dataset-prefix |
reddit |
datasets are {prefix}-posts and {prefix}-comments |
--window-hours |
48 |
scan comment batches newer than this many hours |
--full |
off | scan ALL collected comment batches (one-time historical backfill) |
--run-at |
03:00 |
daily run time HH:MM, interpreted in UTC |
--jitter-seconds |
900 |
per-process random delay added to each run time |
--once |
off | run exactly one iteration, then exit (smoke tests) |
--max-fetch |
10000 |
cap on records fetched per iteration |
--dry-run |
off | print candidate fullnames without lock/fetch/uploads |
--skip-merge |
off | leave the batch as JSONL for the daily merge cron |
--force |
off | ignore the day's completion marker |
--rebuild-index |
off | rebuild the prefill index from scratch (one-time full scan) |
--skip-auth |
off | unauthenticated requests (dev only) |
--api-base |
https://oauth.reddit.com |
Reddit API base (same requirement as main.py) |
--qpm-budget |
90 |
max requests per 60 s (under Reddit's 100 QPM) |
--lock-ttl-ms |
3600000 |
S3Lock TTL (crash-safety window) |
Environment: everything main.py needs, plus — unlike the firehose —
S3_ENDPOINT_URL, S3_ACCESS_KEY, and S3_SECRET_KEY are required for
backfill.py (even in --dry-run): its DuckDB scans read parquet over
httpfs, which cannot use instance credentials.
Deployment (see deploy/backfill.service for a ready systemd unit):
run a one-time historical pass first, then enable the daily service:
uv run python backfill.py --once --full --max-fetch 1000 # historical catch-up
cp deploy/backfill.service ~/.config/systemd/user/ # set User= + paths
systemctl --user daemon-reload && systemctl --user enable --now backfillNotes: SIGTERM does not release the S3 lock gracefully — the lock TTL and the
daily completion marker make restarts idempotent. Deleted/removed targets are
simply omitted by /api/info and age out of the window (no permanent skip
list). Backfilled batches are named reddit-firehose-backfill-YYYYMMDD and
merge/dedupe exactly like firehose batches.
The firehose stores each record's author username and, inside the raw
blob, the author's author_fullname (t2_... id) — but no profile details.
authors.py builds a third dataset: a long-running daemon (systemd, same
supervised-process style as backfill.py) that
- scans all merged
{prefix}-posts/{prefix}-commentsparquet (DuckDB over httpfs — cheap at the current scale) for distinct authors; - fetches each author's profile once via Reddit
/user/{name}/about, under the same QPM budget as the firehose; - uploads one record per author to
{prefix}-authors(daily batchreddit-firehose-authors-YYYYMMDD), merges it, and repeats every--poll-interval-seconds.
One query per author. A small author index in S3
({prefix}/_authors/authors-index.parquet) records every author already
fetched — including not_found tombstones for suspended/deleted accounts —
so an author is never queried twice, across restarts and worker instances.
The only re-query path is the staleness window: profiles older than
--refetch-days (default 7) whose author is still present in the datasets
are refreshed, at most once per window (0 disables). A local JSONL cache
(author_profiles_cache.jsonl) absorbs API calls after crashes; it is an
optimization only — the S3 index is authoritative.
Schema ({prefix}-authors, one row per unique author): id/name (the
t2_... fullname — the join key to posts/comments via their
raw -> author_fullname), author (username), author_created_utc,
author_link_karma, author_comment_karma, author_total_karma,
author_awardee_karma, author_awarder_karma, author_is_mod,
author_is_employee, author_is_gold, author_verified,
author_has_verified_email, author_icon_img, author_subreddit,
author_profile_raw (the full /about payload), fetched_at,
first_seen_at, last_seen_at. Like the base datasets, string values are
JSON-encoded in the lake. Suspended/deleted accounts get an index tombstone
but no dataset row; their rows can be found via the index. Refetched
profiles are uploaded as new records in the new daily batch — consumers
dedupe with GROUP BY name.
Concurrency safety uses the same WSS MUTEX + S3Lock scheme as backfill.py
(lock path reddit-firehose-authors, held per iteration, never across the
sleep).
uv run python authors.py [options]
| Option | Default | Meaning |
|---|---|---|
--dataset-prefix |
reddit |
datasets are {prefix}-posts, {prefix}-comments, {prefix}-authors |
--poll-interval-seconds |
300 |
seconds between iterations |
--once |
off | run exactly one iteration, then exit (smoke tests) |
--max-fetch |
500 |
cap on profiles fetched per iteration; leftovers are picked up next iteration |
--refetch-days |
7 |
staleness window for re-fetching; 0 disables |
--batch-suffix |
(none) | append -{suffix} to the daily batch name (testing) |
--cache-file |
author_profiles_cache.jsonl |
local profile cache, one JSON entry per line |
--dry-run |
off | print candidate authors (one JSON object per line) without lock/fetch/uploads |
--skip-merge |
off | leave the batch as JSONL for the daily merge cron |
--skip-auth |
off | unauthenticated requests (dev only) |
--api-base |
https://oauth.reddit.com |
Reddit API base (same requirement as main.py) |
--qpm-budget |
90 |
max requests per 60 s (under Reddit's 100 QPM) |
--lock-ttl-ms |
3600000 |
S3Lock TTL (crash-safety window) |
Environment: everything backfill.py needs — S3_ENDPOINT_URL,
S3_ACCESS_KEY, and S3_SECRET_KEY are required even in --dry-run
(the DuckDB scans read parquet over httpfs) — plus WSS_MUTEX_BASE_URL
(unless --dry-run). Reddit credentials are only required when there is
something to fetch.
Deployment (see deploy/authors.service for a ready systemd unit): run a
one-time catch-up pass first, then enable the service:
uv run python authors.py --once --max-fetch 10000 # one-time catch-up
cp deploy/authors.service ~/.config/systemd/user/ # set User= + paths
systemctl --user daemon-reload && systemctl --user enable --now authorsThe worker is idempotent and incremental: restarts resume from the S3 index, and new firehose batches are picked up automatically once merged.
uv run pytest # offline tests (mock transport + example_data)
uv run python tests/mock_server.py # serve example_data on :9000 for CLI smoke tests
uv run python main.py --subreddits Quebec --api-base http://127.0.0.1:9000 \
--skip-auth --dry-run --max-cycles 1 --state-file /tmp/smoke_state.json- Posts/comments created before collection started are not captured by the
firehose itself; run
backfill.pydaily to fetch records referenced by collected comments. - If a subreddit emits more than
limit × max_pagesitems per cycle, the overflow is a gap (documented sampling behavior); the cursor still advances so no duplicates are produced. - Items that appear late with a
created_utcat or below the cursor (e.g. delayed moderation approvals) are dropped. [removed]/[deleted]bodies are stored as-is.