Give the Redis write queue the folding it never had - #140
Merged
Conversation
#132 was merged but never wired: REDIS_URL had not been set on either service, so `connect()` had always taken the `serializeWrites` fallback. Switching it on in production on 2026-08-19 made things worse, and this is why. `queueWrites` posted one job per caller, and the worker -- correctly at concurrency 1, because SQLite permits one writer -- turned each job into its own remote transaction. `client.js` measures that at about 370ms, so the queue's ceiling was roughly 2.7 writes a second for every process in the cluster combined. The in-process path it replaced had no such global limit: `serializeWrites` is per-process, and the crawl path runs under TURSO_CRAWL_AUTOCOMMIT=1 issuing single-statement `execute()` writes, which neither wrapper intercepts at all. Measured, same database, same hour: item ingestion halved (8,132 rows per ten minutes to 4,132), crawls fell from 1,003 to 582, and an import of 65,474 entries stopped dead for over an hour -- FIFO with one consumer, so its large periodic batch sat behind every small crawl batch. All of it recovered on removing the variable. So the folding moves into `writeFolder.js` and both paths use it. The first caller runs immediately; everyone arriving while it awaits the database is sent as one transaction, up to a statement ceiling. Failure handling is unchanged and now shared: a constraint error splits the group so a neighbouring feed is not collateral damage, while a timeout rejects the group without being multiplied into one retry per caller. The queued path gets its own ceiling, TURSO_QUEUE_GROUP_STATEMENTS, defaulting to 50 rather than borrowing TURSO_WRITE_GROUP_STATEMENTS -- that one is set to 1 in production, folding off, because a five-crawl group once exceeded the 30-second deadline in-process. On the queued path folding is not a tuning knob, it is the difference between a working queue and 2.7 writes a second, so it defaults on and modestly. Also fixed: `connect()` built a fresh Queue and QueueEvents on every call, each opening Redis connections and registering listeners, so the poller logged `MaxListenersExceededWarning: 11 closing listeners added to [Queue]` within minutes and kept climbing. They are now shared per (url, prefix) and refcounted, so `closeWriteQueue` still means something to whoever holds the last one. This does not make it safe to re-enable. Head-of-line blocking is inherent to one FIFO consumer -- a bulk drain can still delay interactive writes, just for less long -- and the ceiling is now transaction-latency / statements-per-group rather than transaction-latency. Measure before setting REDIS_URL again. See #138. The extraction is behaviour-preserving: the eight existing serializeWrites tests pass untouched. 1,101 tests, 0 failures, build clean, on Node 22. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Aug 19, 2026
…140) (#141) Every crawl of a feed that publishes a contact address but names nobody has been failing at the write since author enrichment shipped. In the hour this was found, 985 of 1,385 crawls errored; the queue stopped draining and /crawlstats went with it. `feedContacts` built each contact as `{ url, network }` and dropped the channel element it came from. Both `feed_links.source` and `author_links.source` are `not null`, so the statement bound `undefined` -- which the remote libSQL client will not serialize at all. It throws `Unsupported type of value` before any SQL runs, with no column named and no row to point at, and the crawl recorded it as `could not be crawled`: a publisher who looks down. The population is large and it is not a platform quirk. Any feed with a `<webMaster>` or `managingEditor` address whose name fails the person test takes this path -- WordPress and Substack alike, and Substack additionally names nobody else, so every newsletter on it qualified. Nothing caught it because the local SQLite driver the tests use binds `undefined` as null without complaint. The difference only exists on the wire, so `link-binds.test.js` asserts what the remote client accepts rather than what a local write happens to survive. Two fixes, because either alone leaves a hole: contacts now carry `source` (provenance worth keeping in its own right -- a mailbox from `itunes:owner` is a stronger claim than one from `webMaster`), and the four link bind sites default it, so no caller can put an unbindable value in a not-null column again. Also: a newsletter whose host fills in the iTunes block is no longer a podcast. Substack emits `<itunes:owner>` on every publication it serves and nothing else that looks like a show -- no `itunes:type`, no `podcast:` namespace, image enclosures rather than audio -- and that one tag filed the whole platform under /podcasts. This is the correction the video branch already makes: the tag has to be corroborated by what the feed actually ships. A declared show still stands on its own, so a podcast that has not released an episode yet keeps its category. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Aug 19, 2026
* Carry a contact's provenance, which the crawler was failing without (#140) Every crawl of a feed that publishes a contact address but names nobody has been failing at the write since author enrichment shipped. In the hour this was found, 985 of 1,385 crawls errored; the queue stopped draining and /crawlstats went with it. `feedContacts` built each contact as `{ url, network }` and dropped the channel element it came from. Both `feed_links.source` and `author_links.source` are `not null`, so the statement bound `undefined` -- which the remote libSQL client will not serialize at all. It throws `Unsupported type of value` before any SQL runs, with no column named and no row to point at, and the crawl recorded it as `could not be crawled`: a publisher who looks down. The population is large and it is not a platform quirk. Any feed with a `<webMaster>` or `managingEditor` address whose name fails the person test takes this path -- WordPress and Substack alike, and Substack additionally names nobody else, so every newsletter on it qualified. Nothing caught it because the local SQLite driver the tests use binds `undefined` as null without complaint. The difference only exists on the wire, so `link-binds.test.js` asserts what the remote client accepts rather than what a local write happens to survive. Two fixes, because either alone leaves a hole: contacts now carry `source` (provenance worth keeping in its own right -- a mailbox from `itunes:owner` is a stronger claim than one from `webMaster`), and the four link bind sites default it, so no caller can put an unbindable value in a not-null column again. Also: a newsletter whose host fills in the iTunes block is no longer a podcast. Substack emits `<itunes:owner>` on every publication it serves and nothing else that looks like a show -- no `itunes:type`, no `podcast:` namespace, image enclosures rather than audio -- and that one tag filed the whole platform under /podcasts. This is the correction the video branch already makes: the tag has to be corroborated by what the feed actually ships. A declared show still stands on its own, so a podcast that has not released an episode yet keeps its category. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * A rate limit is a schedule instruction, not a broken feed Crawling faster got us 429s from Substack, and the crawler recorded each one as the publisher's fault: `markCrawlFailure` sets status='error', increments error_count, walks the backoff ladder, and at ten consecutive failures marks the feed dead. Every feed on one backend is throttled in the same minute, so this retires a whole platform for our own crawl rate -- and Substack is a large share of the directory. A 429 now reschedules and touches nothing else. `status`, `error_count`, `last_error` and `last_success_at` are left exactly as they were, and so is `last_fetched_at`: it means "when we last read this publisher", and a throttle is precisely the case where we did not. Stamping it would make a feed we have been bounced from all day look freshly crawled on every staleness report. 503 with a Retry-After is treated the same way. It is the same statement from a server that is briefly unwilling rather than permanently unable. The server picks the interval, since it is the only party that knows when its limit resets. `Retry-After` is parsed in both forms RFC 9110 allows -- a delay in seconds and an HTTP date -- floored at a minute so `Retry-After: 0` cannot spin, capped at a day so a misread date cannot mothball the feed, and defaulted to 30 minutes when the server names nothing. That is deliberately far shorter than the error ladder it replaces: the feed is healthy and we want it back soon. It is the *rate* that has to come down, and lengthening one feed's interval is the wrong instrument for that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Aug 19, 2026
…#144) `REDIS_URL` has never been set on either production service. `connect()` falls back to the in-process queue when it is absent -- deliberately, as what a Redis outage should degrade to -- so every write has been serialized per process while the Redis queue (#132) and the folding built for it (#140) sat dormant. Nothing was broken and nothing was logged. "We have a write queue" and "the write queue is running" looked identical from outside until somebody read the environment, which is a bad property for the component that decides the crawler's throughput. So `connect()` now says which path it took and *why*: the reason is the point, since "in-process" alone cannot distinguish a deliberate local run from a production service missing its broker. Announced once per path per process, because the web app calls `connect()` per request. The decision moves into an exported `writePath()` that returns the path and the reason and opens nothing. That is what made this testable: the first attempt asserted against `connect()` itself and hung the suite, because the client it returns keeps the event loop alive. The decision is the part worth testing; the client is not. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the regression in #138.
What was wrong
#132 was merged but never actually wired —
REDIS_URLhad never been set on either service, soconnect()had always taken theserializeWritesfallback. Switching it on in production on 2026-08-19 made throughput worse.queueWritesposted one job per caller, and the worker — correctly atconcurrency: 1, because SQLite permits one writer — turned each job into its own remote transaction.client.jsmeasures that at ~370ms, so the queue's ceiling was roughly 2.7 writes/second for the whole cluster.The in-process path it replaced had no such global limit:
serializeWritesis per-process, and the crawl path runs underTURSO_CRAWL_AUTOCOMMIT=1issuing single-statementexecute()writes — which neither wrapper intercepts at all.Measured, same database, same hour:
FIFO with one consumer, so the import's large periodic batch sat behind every small crawl batch. That's why it didn't slow down, it stopped.
What this changes
Folding moves to
writeFolder.jsand both paths share it. The first caller runs immediately; everyone arriving while it awaits the database is sent as one transaction, up to a statement ceiling. Failure handling is unchanged and now shared — a constraint error splits the group so a neighbouring feed isn't collateral damage, a timeout rejects the group without being multiplied into one retry per caller.The queued path gets its own ceiling,
TURSO_QUEUE_GROUP_STATEMENTS, default 50. It deliberately does not borrowTURSO_WRITE_GROUP_STATEMENTS, which is set to1in production (folding off) because a five-crawl group once exceeded the 30s deadline in-process. On the queued path folding isn't a tuning knob, it's the difference between a working queue and 2.7 writes/second.Connections are shared.
connect()built a freshQueue+QueueEventson every call, so the poller loggedMaxListenersExceededWarning: 11 closing listeners added to [Queue]within minutes and climbing. Now shared per(url, prefix)and refcounted, socloseWriteQueuestill means something to whoever holds the last one.This does NOT make it safe to re-enable
Two things survive this PR and belong in #138:
transaction-latency / statements-per-groupinstead oftransaction-latency. Better, still finite.Measure before setting
REDIS_URLagain.REDIS_URLremains removed from both services; this PR changes code only.Verification
serializeWritestests pass untouchedpnpm -r test1,101 pass / 0 fail,pnpm buildclean, both on Node 22 (CI's version)🤖 Generated with Claude Code