Skip to content

Import seventeen thousand contacts in minutes, not eighty-five - #64

Merged
ralyodio merged 1 commit into
mainfrom
fix/import-batching
Aug 19, 2026
Merged

Import seventeen thousand contacts in minutes, not eighty-five#64
ralyodio merged 1 commit into
mainfrom
fix/import-batching

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

"i uploaded 17k emails as csv and it doesn't finish"

Measured from your three attempts, still in prod

started rows duration rate status
15:13 2,500 12 min ~200/min open
15:33 4,500 18 min ~250/min complete
17:04 2,500 4 min open

~200 rows/minute → 17,000 rows ≈ 85 minutes. The browser gave up long before. 4,657 people did land.

Cause: chattiness, not volume

Each row cost four sequential Turso round trips — a dedupe SELECT, then three inserts. At ~75ms each that is 300ms per row of almost pure waiting. The work was always trivial; the latency was the entire cost.

A chunk is now one query and one batch: existing mailboxes fetched for the whole chunk in a single IN lookup, every insert sent as one transactional batch.

db.batch is all-or-nothing, so a unique violation would lose the other 499 rows — the old row-at-a-time path is kept as a fallback, reached only when two imports genuinely race on one address.

The second reason it never returned

finish enqueued one enrich_contact job per person — 17,000 inserts inside one HTTP request. And the queue drains 25/tick, so even if it had returned, enrichment would have taken ~11 hours with every crawl queued behind it.

It now enqueues nothing. The set of people needing enrichment is derivable (imported address, no contact_enriched_at), so the worker sweeps it: nothing has to be written for it to begin, it resumes by itself after a crash, and it cannot drift from the people who actually exist — the same argument metering.ts makes for deriving usage rather than incrementing it.

The sweep runs 10 lookups concurrently, because a Gravatar lookup is a network round trip and nothing else. 200/tick clears 17,000 in about 90 minutes, in the background, with no queue rows.

A timestamp rather than a flag, so "never looked up" and "looked up, found nothing" stay distinguishable — most addresses have no profile, and a boolean would retry them forever.

On BullMQ

You suggested BullMQ on Railway. I did not add it, and the numbers are why: the bottleneck was Turso round trips and Gravatar latency, and Redis fixes neither. REDIS_URL is also in the vault but not set on the service, so it would be new infra for a problem that batching and concurrency already solve. Happy to add it if you want the throughput headroom — it is just not what was broken here.

Verification

  • bun test1370 pass, 0 fail across 90 files
  • typecheck (root + apps/web) and format:check clean
  • New test: a 400-row chunk completes in under 20s, which the old path could not do against a local file

🤖 Generated with Claude Code

Measured from the three attempts sitting in production: 2,500 rows in 12
minutes, 4,500 in 18. About 200 rows a minute, so 17,000 rows is roughly
85 minutes and the browser tab gives up long before that. Two of the three
imports are still `open` because nothing ever finished them.

The cause was chattiness, not volume. Each row cost four sequential round
trips to Turso — a dedupe SELECT, then three inserts — and at ~75ms each
that is 300ms per row of almost pure waiting. The work was always trivial;
the latency was the whole cost.

A chunk is now one query and one batch. Existing mailboxes are fetched for
the whole chunk in a single `IN` lookup, and every insert for the chunk is
sent as one transactional batch. `db.batch` is all-or-nothing, so a unique
violation would lose the other four hundred and ninety-nine rows — the old
row-at-a-time path is kept as a fallback for exactly that, reached only
when two imports genuinely race on one address.

Finishing an import no longer enqueues anything. It used to write one
`enrich_contact` job per person: seventeen thousand inserts inside one HTTP
request, which is a second reason it never returned, and a queue that
drains twenty-five a tick would then have taken eleven hours with every
crawl waiting behind it.

The set of people needing enrichment is derivable — everyone with an
imported address and no `contact_enriched_at` — so the worker sweeps it.
Nothing has to be written for that to begin, it resumes by itself after a
crash, and it cannot drift from the people who actually exist. The same
argument `metering.ts` makes for deriving usage rather than incrementing
it.

The sweep runs its lookups concurrently, ten at a time, because a Gravatar
lookup is a network round trip and nothing else — doing them one after
another wastes the entire interval. Two hundred a tick clears seventeen
thousand in about ninety minutes.

A timestamp rather than a flag, so "never looked up" and "looked up, found
nothing" stay distinguishable. Most addresses have no published profile,
and a boolean would retry every one of them forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 4bc81d6 into main Aug 19, 2026
4 checks passed
@ralyodio
ralyodio deleted the fix/import-batching branch August 19, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant