Skip to content

Commit 5cd539e

Browse files
committed
Process remote replies in workers
Add a worker that claims remote replies scrape jobs by origin, persists bounded reply collections, enqueues child reply collections up to the configured depth, and backs off origins when they return HTTP 429. #445 Assisted-by: Codex:gpt-5.5
1 parent d6fc002 commit 5cd539e

3 files changed

Lines changed: 750 additions & 1 deletion

File tree

bin/server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,17 @@ if (NODE_TYPE === "worker" || NODE_TYPE === "all") {
7171
{ federation },
7272
{ startImportWorker, stopImportWorker },
7373
{ startCleanupWorker, stopCleanupWorker },
74+
{ startRemoteReplyScrapeWorker, stopRemoteReplyScrapeWorker },
7475
] = await Promise.all([
7576
import("../src/federation"),
7677
import("../src/import/worker"),
7778
import("../src/cleanup/worker"),
79+
import("../src/federation/replies-worker"),
7880
]);
7981
stopWorkers = () => {
8082
stopImportWorker();
8183
stopCleanupWorker();
84+
stopRemoteReplyScrapeWorker();
8285
};
8386

8487
// Start the Fedify message queue
@@ -93,8 +96,11 @@ if (NODE_TYPE === "worker" || NODE_TYPE === "all") {
9396
// Start the workers for background job processing
9497
startImportWorker();
9598
startCleanupWorker();
99+
startRemoteReplyScrapeWorker();
96100

97-
console.log("Worker started (Fedify queue + Import worker + Cleanup worker)");
101+
console.log(
102+
"Worker started (Fedify queue + Import worker + Cleanup worker + Remote reply scrape worker)",
103+
);
98104
}
99105

100106
// Graceful shutdown handling
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import type { RemoteDocument } from "@fedify/vocab";
2+
import { eq, isNotNull } from "drizzle-orm";
3+
import { beforeEach, describe, expect, it } from "vitest";
4+
5+
import { cleanDatabase } from "../../tests/helpers";
6+
import db from "../db";
7+
import {
8+
accounts,
9+
instances,
10+
posts,
11+
remoteReplyScrapeJobs,
12+
remoteReplyScrapeOrigins,
13+
} from "../schema";
14+
import type { Uuid } from "../uuid";
15+
import { uuidv7 } from "../uuid";
16+
import {
17+
claimRemoteReplyScrapeJob,
18+
processDueRemoteReplyScrapeJobs,
19+
} from "./replies-worker";
20+
21+
const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
22+
23+
async function seedRemoteAccount(username: string, host = "remote.test") {
24+
const id = crypto.randomUUID() as Uuid;
25+
const iri = `https://${host}/@${username}`;
26+
await db
27+
.insert(instances)
28+
.values({
29+
host,
30+
software: "mastodon",
31+
softwareVersion: null,
32+
})
33+
.onConflictDoNothing();
34+
await db
35+
.insert(accounts)
36+
.values({
37+
id,
38+
iri,
39+
type: "Person",
40+
name: username,
41+
handle: `@${username}@${host}`,
42+
bioHtml: "",
43+
emojis: {},
44+
fieldHtmls: {},
45+
aliases: [],
46+
protected: false,
47+
inboxUrl: `${iri}/inbox`,
48+
followersUrl: `${iri}/followers`,
49+
sharedInboxUrl: `https://${host}/inbox`,
50+
featuredUrl: `${iri}/featured`,
51+
instanceHost: host,
52+
published: new Date(),
53+
})
54+
.onConflictDoNothing();
55+
const account = await db.query.accounts.findFirst({
56+
where: eq(accounts.iri, iri),
57+
});
58+
if (account == null) throw new Error("Failed to seed remote account");
59+
return account;
60+
}
61+
62+
async function seedPostWithScrapeJob({
63+
host = "remote.test",
64+
postIri = `https://${host}/@author/posts/root`,
65+
repliesIri = `https://${host}/@author/posts/root/replies`,
66+
}: {
67+
host?: string;
68+
postIri?: string;
69+
repliesIri?: string;
70+
} = {}) {
71+
const author = await seedRemoteAccount("author", host);
72+
const postId = uuidv7();
73+
await db.insert(posts).values({
74+
id: postId,
75+
iri: postIri,
76+
type: "Note",
77+
accountId: author.id,
78+
visibility: "public",
79+
contentHtml: "<p>Root</p>",
80+
content: "Root",
81+
tags: {},
82+
emojis: {},
83+
sensitive: false,
84+
published: new Date(),
85+
updated: new Date(),
86+
});
87+
await db
88+
.insert(remoteReplyScrapeOrigins)
89+
.values({
90+
originHost: new URL(repliesIri).host,
91+
nextRequestAt: new Date(0),
92+
})
93+
.onConflictDoNothing();
94+
const jobId = uuidv7();
95+
await db.insert(remoteReplyScrapeJobs).values({
96+
id: jobId,
97+
postId,
98+
postIri,
99+
repliesIri,
100+
baseUrl: "https://hollo.test",
101+
originHost: new URL(repliesIri).host,
102+
nextAttemptAt: new Date(0),
103+
});
104+
return { jobId, postId, postIri, repliesIri };
105+
}
106+
107+
function makeLoader(
108+
documents: Record<string, unknown>,
109+
onLoad?: (url: string) => void,
110+
) {
111+
return async (url: string): Promise<RemoteDocument> => {
112+
onLoad?.(url);
113+
const document = documents[url];
114+
if (document == null) throw new Error(`Unexpected fetch: ${url}`);
115+
return {
116+
contextUrl: null,
117+
document,
118+
documentUrl: url,
119+
};
120+
};
121+
}
122+
123+
function actor(username: string, host = "remote.test") {
124+
const iri = `https://${host}/@${username}`;
125+
return {
126+
"@context": "https://www.w3.org/ns/activitystreams",
127+
id: iri,
128+
type: "Person",
129+
name: username,
130+
inbox: `${iri}/inbox`,
131+
followers: `${iri}/followers`,
132+
};
133+
}
134+
135+
function reply({
136+
content = "Reply",
137+
id,
138+
replyTarget,
139+
replies,
140+
username = "replyer",
141+
}: {
142+
content?: string;
143+
id: string;
144+
replyTarget: string;
145+
replies?: string;
146+
username?: string;
147+
}) {
148+
return {
149+
id,
150+
type: "Note",
151+
attributedTo: `https://remote.test/@${username}`,
152+
content: `<p>${content}</p>`,
153+
inReplyTo: replyTarget,
154+
to: PUBLIC_COLLECTION,
155+
replies,
156+
};
157+
}
158+
159+
function collection(id: string, orderedItems: unknown[]) {
160+
return {
161+
"@context": "https://www.w3.org/ns/activitystreams",
162+
id,
163+
type: "OrderedCollection",
164+
totalItems: orderedItems.length,
165+
orderedItems,
166+
};
167+
}
168+
169+
describe("remote replies scrape worker", () => {
170+
beforeEach(async () => {
171+
await cleanDatabase();
172+
});
173+
174+
it("limits how many reply items a single job persists", async () => {
175+
expect.assertions(3);
176+
const { postIri, repliesIri } = await seedPostWithScrapeJob();
177+
await seedRemoteAccount("replyer");
178+
179+
const firstReply = "https://remote.test/@replyer/posts/1";
180+
const secondReply = "https://remote.test/@replyer/posts/2";
181+
const processed = await processDueRemoteReplyScrapeJobs({
182+
documentLoader: makeLoader({
183+
[repliesIri]: collection(repliesIri, [
184+
reply({ id: firstReply, replyTarget: postIri }),
185+
reply({ id: secondReply, replyTarget: postIri }),
186+
]),
187+
"https://remote.test/@replyer": actor("replyer"),
188+
}),
189+
maxItems: 1,
190+
sleep: async () => undefined,
191+
});
192+
193+
const replyPosts = await db.query.posts.findMany({
194+
where: isNotNull(posts.replyTargetId),
195+
orderBy: posts.iri,
196+
});
197+
const job = await db.query.remoteReplyScrapeJobs.findFirst();
198+
expect(processed).toBe(1);
199+
expect(replyPosts.map((post) => post.iri)).toEqual([firstReply]);
200+
expect(job?.fetchedItems).toBe(1);
201+
});
202+
203+
it("scrapes replies to replies up to the configured depth", async () => {
204+
expect.assertions(3);
205+
const { postIri, repliesIri } = await seedPostWithScrapeJob();
206+
await seedRemoteAccount("replyer");
207+
208+
const directReply = "https://remote.test/@replyer/posts/1";
209+
const directReplyReplies = "https://remote.test/@replyer/posts/1/replies";
210+
const nestedReply = "https://remote.test/@replyer/posts/1-1";
211+
const nestedReplyReplies = "https://remote.test/@replyer/posts/1-1/replies";
212+
const documentLoader = makeLoader({
213+
[repliesIri]: collection(repliesIri, [
214+
reply({
215+
id: directReply,
216+
replyTarget: postIri,
217+
replies: directReplyReplies,
218+
}),
219+
]),
220+
[directReplyReplies]: collection(directReplyReplies, [
221+
reply({
222+
id: nestedReply,
223+
replyTarget: directReply,
224+
replies: nestedReplyReplies,
225+
}),
226+
]),
227+
"https://remote.test/@replyer": actor("replyer"),
228+
});
229+
230+
await processDueRemoteReplyScrapeJobs({
231+
documentLoader,
232+
intervalSeconds: 0,
233+
maxDepth: 2,
234+
sleep: async () => undefined,
235+
});
236+
await processDueRemoteReplyScrapeJobs({
237+
documentLoader,
238+
intervalSeconds: 0,
239+
maxDepth: 2,
240+
sleep: async () => undefined,
241+
});
242+
243+
const replyPosts = await db.query.posts.findMany({
244+
where: isNotNull(posts.replyTargetId),
245+
orderBy: posts.iri,
246+
});
247+
const jobs = await db.query.remoteReplyScrapeJobs.findMany({
248+
orderBy: remoteReplyScrapeJobs.depth,
249+
});
250+
expect(replyPosts.map((post) => post.iri)).toEqual([
251+
directReply,
252+
nestedReply,
253+
]);
254+
expect(jobs.map((job) => job.repliesIri)).toEqual([
255+
repliesIri,
256+
directReplyReplies,
257+
]);
258+
expect(jobs.map((job) => job.status)).toEqual(["completed", "completed"]);
259+
});
260+
261+
it("does not claim another job for an origin that is already processing", async () => {
262+
expect.assertions(2);
263+
const first = await seedPostWithScrapeJob();
264+
await seedPostWithScrapeJob({
265+
postIri: "https://remote.test/@author/posts/second",
266+
repliesIri: "https://remote.test/@author/posts/second/replies",
267+
});
268+
269+
const claimed = await claimRemoteReplyScrapeJob("test-worker");
270+
const skipped = await claimRemoteReplyScrapeJob("test-worker");
271+
272+
expect(claimed?.id).toBe(first.jobId);
273+
expect(skipped).toBeNull();
274+
});
275+
276+
it("backs off jobs and origins when a replies collection returns HTTP 429", async () => {
277+
expect.assertions(4);
278+
const { jobId, repliesIri } = await seedPostWithScrapeJob();
279+
const now = new Date("2026-04-25T00:00:00.000Z");
280+
const error = new Error("rate limited") as Error & {
281+
response: Response;
282+
};
283+
error.response = new Response(null, {
284+
status: 429,
285+
headers: { "Retry-After": "120" },
286+
});
287+
288+
const processed = await processDueRemoteReplyScrapeJobs({
289+
documentLoader: async (url) => {
290+
if (url === repliesIri) throw error;
291+
throw new Error(`Unexpected fetch: ${url}`);
292+
},
293+
now,
294+
sleep: async () => undefined,
295+
});
296+
297+
const job = await db.query.remoteReplyScrapeJobs.findFirst({
298+
where: eq(remoteReplyScrapeJobs.id, jobId),
299+
});
300+
const origin = await db.query.remoteReplyScrapeOrigins.findFirst();
301+
expect(processed).toBe(0);
302+
expect(job?.status).toBe("pending");
303+
expect(job?.nextAttemptAt.getTime()).toBe(now.getTime() + 120_000);
304+
expect(origin?.nextRequestAt.getTime()).toBe(now.getTime() + 120_000);
305+
});
306+
});

0 commit comments

Comments
 (0)