Repository navigation
match-funders: move grant page discovery out of the request (Trello #285) - #300
Merged
Merged
Conversation
…285) Funder matching missed its 20 s SLA (4 timeouts at 30 s on Oct 11; real visitors at ~21.5 s). DB work finished in ~6 s; the rest was live homepage scanning for grant application links: - chunks of 6 homepages ran one after another, each allowed 3 s, so 30 sites added ~15 s; - the 3 s timer was cleared once headers arrived, so a slow body read had no limit at all (reproduced: the old helper still hung after 4 s with a 500 ms timeout); - only hits were stored, so the 93% of homepages without a grant link were fetched again on every search. Discovery moves to discovery.ts: one 3 s budget for the whole batch (8 at a time) that covers headers and body, reads at most 500K chars, and scans the matched funders only. Homepages scanned (hit or miss) are recorded in the new funders.apply_url_checked_at and skipped for 30 days. That lookup fails open, so the function works before the migration is applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
Deploying funder-finder with
|
| Latest commit: |
58f6e92
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c5482d49.funder-finder.pages.dev |
| Branch Preview URL: | https://claude-match-funders-discove.funder-finder.pages.dev |
- Discover over the returned results, best-ranked first, after filtering and sorting (and before the mission-path cache write), so the budget isn't spent on funders that are dropped; hits update the row's next step. - Cap discovery at what's left of the request budget; skip it under 250 ms. - Recently-checked lookup: batches in parallel, inside the discovery budget. - Only definite outcomes count as checked (a page scanned, 404/410, not HTML); network errors, 5xx, 429 and bot walls are retried next request. - Resolve links against the page's URL after redirects. - Same-site check by host suffix, not substring (notfund.org, fund.org.x.com). - Save discovered_apply_url in its own PATCH, so hits are saved before the apply_url_checked_at migration; writes go through sbFetch (timeout, keys). - Flush the streaming TextDecoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- A spent budget (even negative) returns no discovery instead of throwing from AbortSignal.timeout and failing the request. - Re-derive a hit's next step from the funder row (its DB next_step), not the result row, whose next_step already holds derived text. - Column is now apply_url_recheck_at (migration not yet applied): 30 days after a miss, 1 day after a failure or budget cutoff, so homepages that always fail don't take the budget on every request. - A homepage that redirects off the funder's site counts as a miss; links must be on the funder's host or a subdomain of it (not its parent). - Discovery runs alongside grantee EIN enrichment; record writes are kept alive with EdgeRuntime.waitUntil and go through sbFetchBatched (which gains a timeout and request init) instead of a copied batching loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
Three review rounds kept finding edge cases in budgeting a homepage scrape inside the visitor's request (cutoffs, the recheck lookup's latency, a negative budget). Discovery now runs after the response is sent (EdgeRuntime.waitUntil) with an 8 s budget, and later searches read its hits from discovered_apply_url. Matching pays nothing for it. - Links must be on the funder's original site (or a subdomain), resolved against the page's URL after redirects, so fund.org -> home.fund.org linking www.fund.org/apply is found and off-site redirects find nothing. - Sites cut off by the budget aren't recorded; real load failures retry after a day, misses after 30 days. - Each website is fetched once even when several funders share it. - Changing a funder's website clears apply_url_recheck_at (trigger). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Hits found after the response update the rows' next step (re-derived from the funder) and, on the mission path, the cached results, so cached searches pick up grant links found after they were cached. - Background discovery starts after EIN enrichment, not alongside it. - A site still loading after 5 s when the budget runs out counts as failed (retried in a day), so sites that always hang don't hold a slot on every search; sites cut off sooner are left for next time. - A response without a Content-Type is read as HTML, not a 30-day miss. - Changing a funder's website also clears the discovered_apply_url found on the old site, unless the same update sets a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- abortable(): with an already-aborted signal, handle the dropped promise's rejection, which was unhandled and ended the isolate (reproduced in a test). - A homepage that redirects up to a parent domain (foundation.bigcorp.com -> www.bigcorp.com/foundation/) is scanned for links under the landing path; only http(s) links count. - Hits are saved only while the funder's website is still the one scanned. - The mission path's cache update only replaces the results it wrote (created_at guard), and re-derives next steps there; the peer path, which has no cache, no longer re-derives rows nobody reads. - The website trigger compares hosts (funder_website_host), so adding https:// or www. doesn't clear discovered_apply_url. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Scan websites as the rest of the function links them (toExternalUrl): trimmed, legacy github.io prefix stripped, relative values skipped. - Miss and failure recheck writes, like hits, apply only while the funder's website is still the one scanned (one PATCH per website). - bareHost() helper for the host normalization funderScope repeated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Score a link's path and query, not its host: every link on fundingfutures.org or grants.fund.org isn't a grant page. - Never pick the page scanned (e.g. the /foundation/ landing page after a redirect) as its own grant page. - Fetch homepages through _shared/safe_fetch.ts, which blocks private and metadata hosts and re-checks every redirect hop. - The website trigger compares host and path (funder_website_key), so a move to another page on the same host (bigcorp.com -> /foundation) resets discovery; adding https://, www. or a trailing slash still doesn't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Cached mission results pick up grant pages any search has discovered since (one funders lookup on a cache hit, 1.5 s cap, best effort). - The search cache is written after grantee EIN enrichment, so cached rows are the same whether or not discovery later finds anything. - Each homepage gets 4 s (DISCOVERY_SITE_TIMEOUT_MS); slower ones count as failed and the batch moves on. Replaces the "hung after 5 s" rule. - A host safeFetch refuses (no DNS answer, private address) is a 30-day miss, not a daily retry. - safeFetch cancels the unread body of each redirect response it follows. - The website trigger keeps apply_url_recheck_at when the same update sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- safeFetch refusals: only private/metadata addresses are a 30-day miss; DNS failures and redirect loops retry in a day. - A website stored as a site section (bigcorp.com/foundation) keeps links to that section; home and language pages (/home, /index.html, /en/) are still the whole site. - A page that finished loading as the batch budget ran out is still scanned. - applyDiscoveredLink() shared by cache hits and the background onHits; cache hits that pick up new links save them back (created_at guard). - Hits sharing a URL are saved in one PATCH; the background closure keeps only each funder's DB next_step, not the candidate rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Cached mission searches run discovery in the background too, and any grant pages found (by them or any search) are written into the cache entry; the cache-hit response no longer waits on a lookup. - After discovery, both paths pick up hits by re-reading them from funders (withDiscoveredLinks), replacing the duplicated next-step update. - A homepage's section comes from where it was served after redirects; a file (about.html) stands for its folder rather than a section no link can be under. - safeFetch refusals are read from the message's fixed wording, not words that can also appear in a host name (privatefoundation.org). - Map.groupBy for grouping by website; a malformed cached results value is served as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Drop the same-site "section" rule: each variant (stored paths, landing paths, file names) shut out real links somewhere. Links anywhere on the funder's site count again, as before this PR; only a redirect up to a parent domain still limits them to the landing path. - Don't rescan funders given a link since the results were built: the recheck lookup also skips rows with apply_url or discovered_apply_url set, and cached results pick up known links before discovery runs. - A grant portal's own root (https://grants.fund.org/) can be the link; only the funder's homepage and the page scanned are skipped. - Content-Type compared case-insensitively. - safeFetch looks up A and AAAA records together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
URL parsing rewrites http://[::ffff:169.254.169.254]/ as [::ffff:a9fe:a9fe], and isPrivateIPv6 only recognised the dotted IPv4-mapped form, so a redirect to that address reached the cloud metadata endpoint (reproduced in the new test). Match-funders discovery now fetches through safeFetch, as fetch-grant-deadline already did. isPrivateIPv6 now expands the address to its 8 groups and checks the IPv4 address embedded in IPv4-mapped, IPv4-compatible and NAT64 forms however it's spelled; ranges compared numerically; unparseable refused. safe_fetch_test.ts added to CI. Also: fresh mission searches skip the found-links lookup before discovery, since their rows were just built from funders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Scan funders with their website as it is now: the due-for-scan lookup returns id and website for funders still without a link, so a cached result's stale website isn't fetched (and its writes don't miss). - Before the apply_url_recheck_at migration, cache hits skip discovery (nothing could be recorded, so every hit would rescan); fresh searches scan as before. - Cache refresh re-reads links only when discovery found some. - safeFetch passes the abort signal to its DNS lookups, and also refuses site-local fec0::/10, local-use NAT64 64:ff9b:1::/48, IPv4-translated ::ffff:0:0:0/96 and 6to4 2002::/16 carrying a private IPv4 address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- Scan in result rank order: the due-for-scan lookup returns rows in
database order, which decided who the 8 s budget reached.
- safeFetch: a DNS lookup cut short by the caller's signal throws the
abort, not SSRFBlockedError('did not resolve') (fetch-grant-deadline
would have answered a slow lookup with a misleading 400).
- A grant portal on its own subdomain scores by its name
(grants.fund.org/ reads as /grants/), as before path-only scoring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
- abortable(): a response that arrives after the abort has its body cancelled instead of holding the connection until GC. - Anchor text is capped at 2,000 characters in the scan, so pages with unclosed <a> tags can't make it quadratic. - needsGrantLink() shared by discovery and the found-links lookup. - safeFetch also refuses Teredo (2001::/32). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
SpikeyCoder
marked this pull request as ready for review
October 13, 2026 01:07
Applied to production as 20261013014519. CREATE OR REPLACE TRIGGER in place of DROP TRIGGER IF EXISTS + CREATE TRIGGER (same result, re-runnable). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm
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 Trello #285 ([SLA] Funder matching: 2 of 3 checks failed in the last 3 hours).
Findings
discoverGrantUrlsInBatchscanning funder homepages for grant-application links inside the request.Changes
EdgeRuntime.waitUntil, so matching never waits on homepage fetches. Hits go todiscovered_apply_url, and later searches read them from there. Cached mission results also run discovery in the background and pick up links found by any search; changes are written back intosearch_cache, guarded bycreated_at.discovery.ts(new):20261013014519_funder_apply_url_recheck_at.sql):funders.apply_url_recheck_at;websiteto another page clears that column and the old site'sdiscovered_apply_url, unless the same update sets them. Rewriting the same page (addinghttps://,www.or a trailing slash) doesn't trigger it._shared/safe_fetch.ts(homepages are now fetched through it):[::ffff:169.254.169.254]into[::ffff:a9fe:a9fe], which the old check let through to the metadata endpoint; reproduced in a test. IPv4-mapped, IPv4-translated, IPv4-compatible, NAT64 and 6to4 forms are now checked, and site-local, local-use NAT64 and Teredo are refused.match-funders/discovery_test.tsand_shared/safe_fetch_test.ts(24 Deno tests), both added to CI.Deploy
Apply the migration: done, applied to production as20261013014519on 2026-10-13. The file is named to match.match-fundersandfetch-grant-deadline(sharedsafe_fetch.ts), both with--no-verify-jwtas now.The function works with or without the migration: hits are saved, fresh searches scan, and cached searches skip discovery until the column exists.
Review
This went through repeated
/code-reviewand/security-reviewrounds. Every security review came back clean; the one security issue, the IPv6 SSRF bypass above, was caught by code review and fixed. Code review converged on these, left as is deliberately:main. Each attempt to limit links to the website's path rejected real links somewhere (/home,/en/,/about.html,/pages/default.aspx).apply_url_recheck_atwould give coverage independent of traffic, and is a good follow-up once this ships.fetchresolve again. That gap predates this PR.Testing
deno checkon match-funders shows the same 2 errors asmain; fetch-grant-deadline is clean.npm run lint,npm run buildandnpm testpass.🤖 Generated with Claude Code
https://claude.ai/code/session_01UPYZwv6X3kmDoJN7g4RaJm