Skip to content

Commit eb343eb

Browse files
authored
Merge pull request #1256 from sij411/fix/backfill-follow
Fix `@fedify/backfill` not follow collection pages during conversation backfill
2 parents ffa5f1f + 7f6a63a commit eb343eb

5 files changed

Lines changed: 342 additions & 6 deletions

File tree

‎CHANGES.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ Version 2.3.12
88

99
To be released.
1010

11+
### @fedify/backfill
12+
13+
- Fixed conversation backfill skipping posts in paginated context and replies
14+
collections. Backfill now follows the first and subsequent pages while
15+
respecting traversal limits, and retains posts already found if a page
16+
cannot be loaded. [[#1248], [#1256] by Jiwon Kwon\]
17+
18+
[#1248]: https://github.com/fedify-dev/fedify/issues/1248
19+
[#1256]: https://github.com/fedify-dev/fedify/pull/1256
20+
1121

1222
Version 2.3.11
1323
--------------
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
links:
3+
'#1248': https://github.com/fedify-dev/fedify/issues/1248
4+
'#1256': https://github.com/fedify-dev/fedify/pull/1256
5+
---
6+
- Fixed conversation backfill skipping posts in paginated context and replies
7+
collections. Backfill now follows the first and subsequent pages while
8+
respecting traversal limits, and retains posts already found if a page
9+
cannot be loaded. [[#1248], [#1256] by Jiwon Kwon]

‎packages/backfill/README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ for await (
6565
The seed object itself is not yielded. If it appears in the discovered
6666
collection, it is skipped by ID.
6767

68+
Context and `replies` collections support inline items and pagination through
69+
`first` and successive `next` links. A collection page used as the starting
70+
collection also follows `next`. Pages are loaded as needed, and traversal
71+
stops on repeated page links. Pagination does not increase reply-tree depth.
72+
If a page cannot be loaded, items already yielded are retained and later
73+
configured strategies can continue unless cancellation or traversal limits
74+
stop them.
75+
6876
Configured strategies run in order. They share `maxItems`, `maxRequests`,
6977
abort state, and object ID deduplication; if two strategies discover the same
7078
object, the first strategy keeps its `BackfillItem` metadata.
@@ -133,7 +141,7 @@ All configured strategies share the same traversal controls:
133141
- `maxItems` limits the number of yielded objects. Skipped duplicates do
134142
not count.
135143
- `maxRequests` limits calls to `documentLoader`. Embedded objects and
136-
collections do not count.
144+
collections, including embedded pages, do not count.
137145
- `maxDepth` limits reply-tree traversal and defaults to 10. It does not
138146
limit context collection items.
139147
- `interval` adds a delay between loader requests. Its callback receives

‎packages/backfill/src/backfill.test.ts‎

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict";
22
import test, { describe } from "node:test";
33
import { backfill, type BackfillContext, MaxRequestsExceeded } from "./mod.ts";
4-
import { Announce, Collection, Create, Note } from "@fedify/vocab";
4+
import {
5+
Announce,
6+
Collection,
7+
CollectionPage,
8+
Create,
9+
Note,
10+
OrderedCollection,
11+
OrderedCollectionPage,
12+
} from "@fedify/vocab";
513

614
async function collect(
715
context: BackfillContext,
@@ -1772,3 +1780,251 @@ describe("backfill", () => {
17721780
deepStrictEqual(iterations, [0, 1]);
17731781
});
17741782
});
1783+
1784+
describe("collection pagination", () => {
1785+
const iri = (name: string) => new URL(`https://example.com/${name}`);
1786+
1787+
for (const ordered of [false, true]) {
1788+
for (const replies of [false, true]) {
1789+
for (const embedded of [false, true]) {
1790+
test(`${ordered ? "ordered" : "unordered"} ${replies ? "replies" : "context"} with ${embedded ? "embedded" : "linked"} pages`, async () => {
1791+
const CollectionType = ordered ? OrderedCollection : Collection;
1792+
const PageType = ordered ? OrderedCollectionPage : CollectionPage;
1793+
const a = new Note({ id: iri("a") });
1794+
const b = new Note({ id: iri("b") });
1795+
const second = new PageType({ id: iri("page-2"), items: [b] });
1796+
const first = new PageType({
1797+
id: iri("page-1"),
1798+
items: [a],
1799+
next: embedded ? second : second.id,
1800+
});
1801+
const collection = new CollectionType({
1802+
id: iri("thread"),
1803+
first: embedded ? first : first.id,
1804+
});
1805+
const seed = new Note({
1806+
id: iri("seed"),
1807+
contexts: replies ? [] : [iri("thread")],
1808+
replies: replies ? collection : null,
1809+
});
1810+
const documents = new Map<string, Collection>([
1811+
[iri("thread").href, collection],
1812+
[iri("page-1").href, first],
1813+
[iri("page-2").href, second],
1814+
]);
1815+
const calls: string[] = [];
1816+
const context: BackfillContext = {
1817+
documentLoader: (url) => {
1818+
calls.push(url.href);
1819+
return Promise.resolve(documents.get(url.href) ?? null);
1820+
},
1821+
};
1822+
const items = await collect(context, seed, {
1823+
strategies: replies ? ["reply-tree"] : ["context-auto"],
1824+
maxDepth: 1,
1825+
maxRequests: embedded ? (replies ? 0 : 1) : 3,
1826+
});
1827+
deepStrictEqual(items.map((item) => item.id?.href), [
1828+
a.id?.href,
1829+
b.id?.href,
1830+
]);
1831+
deepStrictEqual(
1832+
items.map((item) => item.depth),
1833+
replies ? [1, 1] : [0, 0],
1834+
);
1835+
strictEqual(
1836+
calls.length,
1837+
embedded ? (replies ? 0 : 1) : (replies ? 2 : 3),
1838+
);
1839+
});
1840+
}
1841+
}
1842+
}
1843+
1844+
test("starting page follows next and stops before loading a repeated page", async () => {
1845+
const first = new OrderedCollectionPage({
1846+
id: iri("page-1"),
1847+
items: [new Note({ id: iri("a") })],
1848+
next: iri("page-2"),
1849+
});
1850+
const second = new OrderedCollectionPage({
1851+
id: iri("page-2"),
1852+
items: [new Note({ id: iri("b") })],
1853+
next: first.id,
1854+
});
1855+
const calls: string[] = [];
1856+
const items = await collect({
1857+
documentLoader: (url) => {
1858+
calls.push(url.href);
1859+
return Promise.resolve(url.href === first.id?.href ? first : second);
1860+
},
1861+
}, new Note({ contexts: [first.id!] }));
1862+
deepStrictEqual(items.map((item) => item.id?.href), [
1863+
iri("a").href,
1864+
iri("b").href,
1865+
]);
1866+
deepStrictEqual(calls, [first.id?.href, second.id?.href]);
1867+
});
1868+
1869+
for (const limit of ["maxItems", "maxRequests"] as const) {
1870+
test(`${limit} stops between pages`, async () => {
1871+
const first = new CollectionPage({
1872+
id: iri("page-1"),
1873+
items: [new Note({ id: iri("a") })],
1874+
next: iri("page-2"),
1875+
});
1876+
const calls: string[] = [];
1877+
const items = await collect(
1878+
{
1879+
documentLoader: (url) => {
1880+
calls.push(url.href);
1881+
return Promise.resolve(first);
1882+
},
1883+
},
1884+
new Note({
1885+
contexts: [first.id!],
1886+
replies: new Collection({
1887+
items: [new Note({ id: iri("fallback") })],
1888+
}),
1889+
}),
1890+
{ [limit]: 1, strategies: ["context-auto", "reply-tree"] },
1891+
);
1892+
deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]);
1893+
deepStrictEqual(calls, [first.id?.href]);
1894+
});
1895+
}
1896+
1897+
for (const failure of ["missing", "throw", "invalid"] as const) {
1898+
test(`${failure} page retains items and allows the next strategy`, async () => {
1899+
const seed = new Note({
1900+
contexts: [iri("thread")],
1901+
replies: new Collection({ items: [new Note({ id: iri("reply") })] }),
1902+
});
1903+
const first = new CollectionPage({
1904+
items: [new Note({ id: iri("a") })],
1905+
next: iri("broken"),
1906+
});
1907+
const items = await collect(
1908+
{
1909+
documentLoader: (url) => {
1910+
if (url.href === iri("thread").href) {
1911+
return Promise.resolve(new Collection({ first }));
1912+
}
1913+
if (failure === "throw") throw new Error("page failed");
1914+
return Promise.resolve(failure === "missing" ? null : new Note({}));
1915+
},
1916+
},
1917+
seed,
1918+
{ strategies: ["context-auto", "reply-tree"] },
1919+
);
1920+
deepStrictEqual(items.map((item) => item.id?.href), [
1921+
iri("a").href,
1922+
iri("reply").href,
1923+
]);
1924+
});
1925+
}
1926+
1927+
test("inline items precede page items", async () => {
1928+
const items = await collect({
1929+
documentLoader: () =>
1930+
Promise.resolve(
1931+
new Collection({
1932+
items: [new Note({ id: iri("inline") })],
1933+
first: new CollectionPage({
1934+
items: [new Note({ id: iri("paged") })],
1935+
}),
1936+
}),
1937+
),
1938+
}, new Note({ contexts: [iri("thread")] }));
1939+
deepStrictEqual(items.map((item) => item.id?.href), [
1940+
iri("inline").href,
1941+
iri("paged").href,
1942+
]);
1943+
});
1944+
1945+
test("cancellation during a page load prevents later strategies", async () => {
1946+
const controller = new AbortController();
1947+
const reason = new Error("cancelled");
1948+
const seed = new Note({
1949+
contexts: [iri("thread")],
1950+
replies: new Collection({ items: [new Note({ id: iri("fallback") })] }),
1951+
});
1952+
await rejects(
1953+
collect(
1954+
{
1955+
documentLoader: (url, options) => {
1956+
strictEqual(options?.signal, controller.signal);
1957+
if (url.href === iri("thread").href) {
1958+
return Promise.resolve(new Collection({ first: iri("page") }));
1959+
}
1960+
controller.abort(reason);
1961+
throw reason;
1962+
},
1963+
},
1964+
seed,
1965+
{
1966+
signal: controller.signal,
1967+
strategies: ["context-auto", "reply-tree"],
1968+
},
1969+
),
1970+
(error) => error === reason,
1971+
);
1972+
});
1973+
});
1974+
1975+
test("page loads share the cache and interval across strategies", async () => {
1976+
const iri = (name: string) => new URL(`https://example.com/${name}`);
1977+
const collection = new Collection({ first: iri("page") });
1978+
const page = new CollectionPage({ items: [new Note({ id: iri("a") })] });
1979+
const seed = new Note({
1980+
contexts: [iri("thread")],
1981+
replies: new Collection({ first: iri("page") }),
1982+
});
1983+
const calls: string[] = [];
1984+
const intervals: number[] = [];
1985+
const items = await collect(
1986+
{
1987+
documentLoader: (url) => {
1988+
calls.push(url.href);
1989+
return Promise.resolve(
1990+
url.href === iri("thread").href ? collection : page,
1991+
);
1992+
},
1993+
},
1994+
seed,
1995+
{
1996+
strategies: ["context-auto", "reply-tree"],
1997+
maxRequests: 2,
1998+
interval: (count) => {
1999+
intervals.push(count);
2000+
return { milliseconds: 0 };
2001+
},
2002+
},
2003+
);
2004+
deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]);
2005+
deepStrictEqual(calls, [iri("thread").href, iri("page").href]);
2006+
deepStrictEqual(intervals, [0, 1]);
2007+
});
2008+
2009+
test("embedded pages without IDs follow next without loader calls", async () => {
2010+
const a = new Note({ id: new URL("https://example.com/a") });
2011+
const b = new Note({ id: new URL("https://example.com/b") });
2012+
const seed = new Note({
2013+
replies: new Collection({
2014+
first: new CollectionPage({
2015+
items: [a],
2016+
next: new CollectionPage({ items: [b] }),
2017+
}),
2018+
}),
2019+
});
2020+
const items = await collect(
2021+
{
2022+
documentLoader: () => {
2023+
throw new Error("embedded pages must not be fetched");
2024+
},
2025+
},
2026+
seed,
2027+
{ strategies: ["reply-tree"], maxRequests: 0, maxDepth: 1 },
2028+
);
2029+
deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
2030+
});

‎packages/backfill/src/backfill.ts‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,8 +517,8 @@ async function* getCollectionItems(
517517
budget: RequestBudget,
518518
skipIds?: ReadonlySet<string>,
519519
): AsyncIterable<APObject | Link> {
520-
yield* collection.getItems({
521-
documentLoader: async (url) => {
520+
const itemOptions = {
521+
documentLoader: async (url: string) => {
522522
return await loadCollectionItemDocument(
523523
context,
524524
url,
@@ -527,8 +527,61 @@ async function* getCollectionItems(
527527
skipIds,
528528
);
529529
},
530-
crossOrigin: "trust",
531-
});
530+
crossOrigin: "trust" as const,
531+
};
532+
const pageOptions = {
533+
documentLoader: async (url: string) => {
534+
const object = await loadObject(
535+
context,
536+
new URL(url),
537+
options,
538+
budget,
539+
true,
540+
);
541+
if (object == null) throw new Error(`Collection page not found: ${url}`);
542+
return {
543+
contextUrl: null,
544+
documentUrl: url,
545+
document: await object.toJsonLd(),
546+
};
547+
},
548+
crossOrigin: "trust" as const,
549+
};
550+
const visitedIds = new Set<string>();
551+
const visitedPages = new WeakSet<BackfillCollection>();
552+
let current: BackfillCollection | null = collection;
553+
while (current != null) {
554+
budget.signal?.throwIfAborted();
555+
if (visitedPages.has(current)) return;
556+
if (current.id != null) {
557+
if (visitedIds.has(current.id.href)) return;
558+
visitedIds.add(current.id.href);
559+
}
560+
visitedPages.add(current);
561+
yield* current.getItems(itemOptions);
562+
563+
const page: CollectionPage | OrderedCollectionPage | null =
564+
current instanceof CollectionPage ||
565+
current instanceof OrderedCollectionPage
566+
? current
567+
: null;
568+
const pageId: URL | null = page == null ? current.firstId : page.nextId;
569+
if (pageId != null && visitedIds.has(pageId.href)) return;
570+
try {
571+
budget.signal?.throwIfAborted();
572+
current = page == null
573+
? await current.getFirst(pageOptions)
574+
: await page.getNext(pageOptions);
575+
} catch (error) {
576+
if (error instanceof MaxRequestsExceeded) throw error;
577+
budget.signal?.throwIfAborted();
578+
return;
579+
}
580+
// Remember the requested IRI too, in case the page has a different ID.
581+
if (pageId != null && pageId.href !== current?.id?.href) {
582+
visitedIds.add(pageId.href);
583+
}
584+
}
532585
}
533586

534587
async function getCreateActivityObject(

0 commit comments

Comments
 (0)