Changeset dc41bef in Klonkt
- Timestamp:
- 07/01/2026 06:23:10 PM (2 months ago)
- Branches:
- main
- Children:
- 43273c9
- Parents:
- 7b07035
- Files:
-
- 1 added
- 2 edited
-
src/routes/posts.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
test/thread-crawl.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/posts.js
r7b07035 rdc41bef 1122 1122 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''); 1123 1123 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site); 1124 // Stale-while-revalidate: render from cache now; refresh the remote thread in the 1125 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view. 1126 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id); 1124 1127 } catch { /* non-fatal */ } 1125 1128 // Owner/admin of this site may reply back to a fediverse interaction. -
src/services/ActivityPubService.js
r7b07035 rdc41bef 1836 1836 } catch { return 0; } 1837 1837 } 1838 1839 // ── Remote thread crawl (fill the gaps in a local post's conversation) ──────────── 1840 // Most replies reach us by delivery, but replies-to-replies that live on other servers and 1841 // aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies 1842 // we DO have, caching any newly-found ones in ap_interactions. Bounded (depth/fetch caps), 1843 // polite (serial), PULL only, and stale-while-revalidate: it never runs in a page request — 1844 // the view renders from cache; a stale post kicks off a background refresh for the NEXT view. 1845 const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour 1846 const THREAD_MAX_DEPTH = 3; // replies-to-replies-to-replies 1847 const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer) 1848 const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views) 1849 1850 function threadCrawlTs(postId) { 1851 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; } 1852 catch { return 0; } 1853 } 1854 function setThreadCrawlTs(postId, ts) { 1855 try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); } 1856 catch { /* ignore */ } 1857 } 1858 1859 // Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) → 1860 // child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped. 1861 async function collectReplyItems(repliesRef, maxPages, budget) { 1862 const uris = []; 1863 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef; 1864 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first; 1865 let pages = 0; 1866 while (node && pages++ < maxPages) { 1867 for (const it of (node.items || node.orderedItems || [])) { 1868 const u = typeof it === 'string' ? it : (it && it.id); 1869 if (u && /^https?:\/\//i.test(u)) uris.push(u); 1870 } 1871 if (!node.next) break; 1872 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next; 1873 } 1874 return uris; 1875 } 1876 1877 async function crawlThread(postId) { 1878 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 1879 if (!base) return; 1880 // Seed frontier = the remote reply note URIs we already have; also the dedup set. 1881 let known; 1882 try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); } 1883 catch { return; } 1884 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u)); 1885 if (!seeds.length) return; // nothing remote to expand 1886 1887 let fetches = 0; 1888 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } }; 1889 const visited = new Set(); // notes whose replies collection we've already expanded 1890 let frontier = seeds.slice(); 1891 let added = 0; 1892 1893 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) { 1894 const nextFrontier = []; 1895 for (const noteUri of frontier) { 1896 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue; 1897 visited.add(noteUri); 1898 const note = await budget.get(noteUri); 1899 if (!note || !note.replies) continue; 1900 const childUris = await collectReplyItems(note.replies, 2, budget); 1901 for (const cu of childUris) { 1902 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue; 1903 known.add(cu); 1904 const child = await budget.get(cu); 1905 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue; 1906 const actorUri = actorUriOf(child.attributedTo); 1907 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors 1908 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle 1909 const ai = actorInfo(actor, actorUri); 1910 const html = HtmlSanitizerService.sanitize(child.content || ''); 1911 // The child replies to `note` by construction (it's in note's replies collection). 1912 try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri); added++; } catch { /* ignore */ } 1913 nextFrontier.push(child.id); // expand this reply's own replies next depth 1914 } 1915 } 1916 frontier = nextFrontier; 1917 } 1918 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)'); 1919 } 1920 1921 // Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing — 1922 // fires a background crawl only if this post hasn't been crawled within the TTL. 1923 export function maybeCrawlThread(postId) { 1924 if (!postId || _crawlingThreads.has(postId)) return; 1925 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return; 1926 _crawlingThreads.add(postId); 1927 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire 1928 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId)); 1929 } 1930 1838 1931 let _selfHealing = false; 1839 1932 export async function selfHealTimeline() { … … 2140 2233 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, 2141 2234 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll, 2142 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, 2235 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, 2143 2236 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 2144 2237 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)