Notifio watches rental searches and tells you when a listing appears. With the auto-reply upgrade it can also fill in that site's contact form for you, replaying a reply you recorded by hand once. The bookkeeping for that lives in one append-only NDJSON file, and I have written about it twice: why it is NDJSON rather than sqlite, and what a torn last line costs.
Both of those posts were about writes. This one is about reads, because I went and counted them and the number is four.
The shape
Every write is an append. An update is a new row with the same id, and readers fold the rows into current state with last write winning:
/** Fold append-only rows into current state, last write wins. */
function fold<T extends { id: string }>(rows: T[]): Map<string, T> {
const byId = new Map<string, T>();
for (const row of rows) {
if (!row || typeof row.id !== 'string') continue;
byId.set(row.id, { ...byId.get(row.id), ...row });
}
return byId;
}
There is no index, no cursor and no partial read. readRows is readFileSync plus a JSON.parse per line, and every question asked of the ledger starts there:
export function isListingHandled(listingId: string): boolean {
const existing = getReplyForListing(listingId); // readRows + fold
return existing ? TERMINAL_STATUSES.has(existing.status) : false;
}
export function countReplies(options: { sinceMs: number; domain?: string; ... }): number {
// ...
for (const reply of fold(readRows<ReplyRecord>(REPLIES_FILE)).values()) {
// ...
}
}
That design is deliberate and I would choose it again. It makes a write atomic by construction, it survives a crash mid-append losing at most the final line, and it needs no native module in a build that already ships a Chromium inside an Electron app.
What I had not done is count how many times one decision calls it.
Four, before anything is written
Before a reply is attempted, the engine refuses it for any of several reasons, in an order chosen by cost. Here are the ones that touch the ledger:
// Never message the same listing twice, whatever happened last time.
if (ledger.isListingHandled(listing.id)) {
return { status: 'duplicate', reason: 'Already handled this listing.' };
}
// ... session check ...
const dayMs = 24 * 60 * 60 * 1000;
if (ledger.countReplies({ sinceMs: dayMs }) >= defaults.DAILY_LIMIT) { ... }
if (ledger.countReplies({ sinceMs: dayMs, domain }) >= defaults.PER_SITE_DAILY_LIMIT) { ... }
if (ledger.countReplies({ sinceMs: 60 * 60 * 1000, domain }) >= defaults.PER_SITE_HOURLY_LIMIT) { ... }
One dedupe lookup and three quota counts. Four calls, four readFileSync of the same file, four full parses, four folds, all synchronous, all within a few microseconds of each other. Three of them could be one pass: the same folded map answers all three counts, since they differ only in the window and the domain filter.
Then, if the reply proceeds, each status transition goes through updateReply, which reads the file to find the current record, appends the new one, and calls the compaction check, which reads the file again:
export function updateReply(id: string, patch: Partial<ReplyRecord>): ReplyRecord | null {
const current = fold(readRows<ReplyRecord>(REPLIES_FILE)).get(id);
if (!current) return null;
// ...
appendRow(REPLIES_FILE, next);
maybeCompactReplies(); // readRows again
return next;
}
So a write is two reads, and a reply that moves through two statuses is four more full parses on top of the four it took to be allowed at all.
What that costs, measured
The cap is in the file:
const REPLIES_MAX_ROWS = 8000;
const REPLIES_KEEP = 2000;
The comment above those two lines says the file gets rewritten once it grows past the first number.
So the worst case is a known size. I could not import the real module to benchmark it, because it reaches ./paths, which imports electron, so I copied readRows and fold verbatim into a standalone script and generated rows with realistic field lengths: a listing URL with a query string on it, a title that is a real sentence, a site name, a domain, a status, a reason, two UUIDs and two ISO timestamps.
$ node bench.mjs 8000
{ "rows": 8000, "fileKB": 4293, "bytesPerRow": 550,
"readRowsMs": 6.1, "readAndFoldMs": 7.9, "fourChecksMs": 31.8 }
$ node bench.mjs 2000
{ "rows": 2000, "fileKB": 1072, "bytesPerRow": 549,
"readRowsMs": 1.6, "readAndFoldMs": 2.0, "fourChecksMs": 8.0 }
A row is about 550 bytes, so the ledger at its cap is 4.3 MB. One read and fold of it is 7.9 ms. The four checks that decide a single listing are 32 ms, and a reply that goes out and updates twice adds another 32.
Thirty-two milliseconds is not a crisis. It is also not happening in a worker. This is the Electron main process, which is the same process that runs the local HTTP server the renderer talks to and pushes the event stream that keeps the UI live. Every one of those parses is a stall in the event loop that serves the window. A poll cycle that considers eight new listings across several searches spends a quarter of a second not answering its own UI, and the thing it is spending it on is re-reading a file it read four times already.
The cap is checked in one place
maybeCompactReplies is called from exactly one function:
$ grep -n "maybeCompact" src/ledger.ts
136: maybeCompactReplies();
177: function maybeCompactReplies(): void {
Line 136 is inside updateReply. createReply appends and returns:
export function createReply(input: ...): ReplyRecord {
const now = new Date().toISOString();
const record: ReplyRecord = { ...input, id: crypto.randomUUID(), attempts: input.attempts ?? 0, createdAt: now, updatedAt: now };
appendRow(REPLIES_FILE, record);
return record;
}
In normal operation that is fine, because a real reply is created as a claim and then updated with its outcome, so it passes through line 136 twice. But the ledger does not only hold replies that happened. Most refusals are recorded too, with a status saying why, so that a user can always answer "why did it not reply to that one?", and a recorded refusal is one createReply and no update at all:
/** Refusals worth retrying on a later poll rather than recording forever. */
const TRANSIENT: ReadonlySet<ReplyStatus> = new Set<ReplyStatus>(['skipped_quota']);
// ...
// Transient refusals are not recorded: recording them would mark the listing
// as handled and it would never be retried once the limit resets.
Quota refusals are the exception and are deliberately not written, because a row saying "not today" would read as handled forever. Everything else is. So the create-only case is not exotic: it is a user with auto-reply switched on, a site they have not recorded a reply for yet or are signed out of, and a search that keeps finding listings. Every new listing is one appended row, nothing updates it, and the function that could notice the file growing is never called.
The fix is one line in createReply, and the reason I am wary of just adding it is the cost above: compaction starts with a full read, so calling it on every append doubles the write cost of the cheapest operation in the file. The honest version is a row counter kept in memory, incremented on append, with the expensive check only when it crosses the threshold.
The part that is not a performance question
Compaction keeps 2,000 of the 8,000:
const folded = Array.from(fold(rows).values())
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.slice(0, REPLIES_KEEP);
Newest 2,000 records, oldest dropped. Which means the promise that reads so confidently in the dedupe check, never message the same listing twice whatever happened last time, is not unbounded. It holds for the last 2,000 reply records. A listing handled before that, reappearing on a site that recycles its URLs, reads as new.
I am comfortable with that, and I would rather write it down than discover it in a support email. The alternative is keeping a listing id forever, which turns a bounded file into an unbounded one, and the whole reason this app has no database is that every file in it has to have a known worst case. What the design owes the user is that the horizon is big enough to be irrelevant in practice and that failures land on the safe side, which is why a failed submit counts as terminal rather than as a reason to try again.
If you want to see the user-facing end of this, auto-reply is a second one-time purchase on the pricing page rather than a subscription, and each supported site's page says plainly whether replying is supported there and what it does: Kamernet, Pararius, SpareRoom and the rest of them. The setup is written up in the help pages, and the advice about what to actually say to a landlord, which no amount of replaying forms will do for you, is in this guide.
Top comments (0)