A silent 1,000-row limit, and the same email sent over and over
If you've posted an engineering job publicly, you may have had an email from me. Some of you had the same one many times. This is what went wrong, why it took months to see, and what changed. I'm sorry.
What the job did
Evaluator is a technical assessment for hiring engineers. In April I set up a scheduled job that sent a short cold email every weekday to people who had posted engineering jobs publicly, many of them in Hacker News "Who is hiring?" threads. It ramped up to 100 emails a day.
Every address went into a ledger table in Postgres, on Supabase, with a unique index on the email column. Before each run the job read the ledger into memory and skipped anyone already in it. That read was the only thing standing between a person and a second copy.
The first bug: the read came back short and said nothing
The read looked like this:
const { data } = await supabase
.from('cold_outreach_sends')
.select('email')
.limit(100000)Supabase serves queries like this through PostgREST, which caps every response at a configured maximum number of rows. On this project the cap was 1,000. Asking for 100,000 doesn't raise it. The response stops at 1,000 rows, with no error and nothing to say more exist.
The ledger passed 1,000 rows on April 24. From then on the job could see at most 1,000 of the people it had already emailed. By September the ledger held 12,928 rows, and everyone outside those 1,000 looked new.
The second bug: the write failed and nobody read the failure
After each send the job inserted a row for the recipient. For someone already in the ledger, the unique index rejected that insert. That rejection should have been the alarm. It wasn't, for two reasons.
The Supabase client doesn't throw on a database error. It returns { data, error }, and this code never looked at error. And the counter that said "sent" went up before the insert ran. So the job sent the email, the database refused to record it, the refusal went nowhere, and the run finished with status 200 and a count that looked fine.
Why it ran for months
Because the rejected inserts wrote nothing, the ledger looked idle. A day of 100 emails left no trace in the table. In July I looked at the ledger, saw no new rows, and concluded the job had stopped sending. I went looking for why it wasn't working. It was working. It was sending the same email to the same people.
Without an ORDER BY, the 1,000 rows PostgREST returned were most likely much the same from one day to the next, and the job walked its contact lists in a fixed order. So on most days each run picked largely the same people from outside that window. That's why this was repeated email to a small group rather than one extra email to many.
How I found it
In September I looked somewhere other than the ledger: the database's API logs. One run, on September 18 at 14:22 UTC, made one read of the ledger and then 100 inserts over 75 seconds. Every insert came back 409, a conflict. The site's analytics showed the other side of it: about 40 visits a week from links in the email, from mid-August on, in weeks where the ledger recorded no email sent at all.
How much
What I can prove: duplicate sends on the September days I have database logs for, and repeated email since at least mid-August, when the site began recording page visits. In those weeks each run most likely went to roughly the same hundred addresses. What I can infer: the bug was live from April 24, and the job was never paused before September 20. From April to July I can't tell how the sends split between new addresses and repeats, and for a stretch of about three weeks a malformed API key made every send fail.
What I can't do is count. The duplicates were never recorded, by construction. The honest answer is thousands of emails, and in August and September some people may have received the same message dozens of times.
What changed
I paused the job the night I found it. Then I rebuilt it around three rules.
- The database is the lock. The job writes the ledger row first and sends only if that write succeeded. A conflict means skip. Whatever the in-memory copy believes, a duplicate can't get past the unique index.
- Reads are paginated. The ledger is read in pages with
.range(), advancing by the number of rows actually returned, so a server cap can't shorten it silently. - Every run leaves a record. A separate table gets one row per run, written when it starts and finished when it ends, including runs that send nothing, with the reason. "No new rows" can't mean "nothing ran" or "everything was a duplicate" anymore.
The tests now run the job against a fake database that clamps responses at 1,000 rows and enforces the unique index. One of them fails if an address whose claim is rejected ever gets sent.
The rebuilt job also reads the email provider's bounce and complaint rates before sending. After the restart it refused every run, because the week's bounce rate was far over the provider's limit, much of it from mailing the same stale addresses again and again. Then I turned cold email off entirely. The lists were scraped and old, and our provider doesn't allow cold outreach. It's off.
If you use Supabase or PostgREST
- A select without
.range()returns at most your max-rows setting, silently. If you need the full set, page through it and advance by rows returned, not by page size. - The client returns errors as values. Check
erroron every write, or callthrowOnError(). - Let the database enforce uniqueness, and claim before you act. A check against an in-memory copy is only as good as the read that built it.
- Log every run, including the ones that do nothing.
- When your data says nothing is happening and people are reacting anyway, believe the people.
If you got these emails
I'm sorry. It was careless, and it took attention you never offered. The job is off. If you'd like your address removed from our records, you can ask at tryevaluator.com/privacy/delete.