Skip to content

Commit e087854

Browse files
authored
Merge pull request #1258 from sij411/fix/1249
Continue backfill after context load failures
2 parents eb343eb + a05a7f4 commit e087854

5 files changed

Lines changed: 203 additions & 7 deletions

File tree

‎CHANGES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ To be released.
1414
collections. Backfill now follows the first and subsequent pages while
1515
respecting traversal limits, and retains posts already found if a page
1616
cannot be loaded. [[#1248], [#1256] by Jiwon Kwon\]
17+
- Fixed conversation backfill stopping when the context collection loader
18+
failed. Failed context loads are now skipped so later configured strategies
19+
can still find accessible posts. Cancellation and interval configuration
20+
errors continue to propagate. [[#1249], [#1258] by Jiwon Kwon\]
1721

1822
[#1248]: https://github.com/fedify-dev/fedify/issues/1248
23+
[#1249]: https://github.com/fedify-dev/fedify/issues/1249
1924
[#1256]: https://github.com/fedify-dev/fedify/pull/1256
25+
[#1258]: https://github.com/fedify-dev/fedify/pull/1258
2026

2127

2228
Version 2.3.11
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
links:
3+
'#1249': https://github.com/fedify-dev/fedify/issues/1249
4+
'#1258': https://github.com/fedify-dev/fedify/pull/1258
5+
---
6+
- Fixed conversation backfill stopping when the context collection loader
7+
failed. Failed context loads are now skipped so later configured strategies
8+
can still find accessible posts. Cancellation and interval configuration
9+
errors continue to propagate. [[#1249], [#1258] by Jiwon Kwon]

‎packages/backfill/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ An `interval` string requires the global `Temporal` API or a polyfill.
153153

154154
If the seed has no context, or its context resolves to a non-collection,
155155
context strategies yield nothing. Loader failures are skipped unless
156-
traversal is aborted.
156+
traversal is aborted. If the context collection cannot be loaded, later
157+
configured strategies can still run. Failed loads consume a request, and
158+
later strategies can use embedded data even when the request budget is
159+
exhausted. Configuration errors, such as an invalid `interval`, still
160+
propagate.
157161

158162
Dereferenced documents are cached in memory for one `backfill()` traversal.
159163
Applications that need persistent or shared caching can provide it through

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

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,3 +2028,167 @@ test("embedded pages without IDs follow next without loader calls", async () =>
20282028
);
20292029
deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
20302030
});
2031+
2032+
describe("context loader failures", () => {
2033+
const contextId = new URL("https://example.com/thread");
2034+
const parent = new Note({ id: new URL("https://example.com/parent") });
2035+
2036+
for (const synchronous of [false, true]) {
2037+
const failure = synchronous ? "synchronous throw" : "promise rejection";
2038+
const fail = () => {
2039+
const error = new Error("Context collection unavailable");
2040+
if (synchronous) throw error;
2041+
return Promise.reject(error);
2042+
};
2043+
2044+
test(`${failure} allows reply-tree to yield an embedded parent`, async () => {
2045+
const seed = new Note({ contexts: [contextId], replyTarget: parent });
2046+
let requests = 0;
2047+
const items = await collect(
2048+
{
2049+
documentLoader: () => {
2050+
requests++;
2051+
return fail();
2052+
},
2053+
},
2054+
seed,
2055+
{ strategies: ["context-auto", "reply-tree"], maxRequests: 1 },
2056+
);
2057+
deepStrictEqual(items.map((item) => item.object), [parent]);
2058+
strictEqual(items[0].strategy, "reply-tree");
2059+
strictEqual(requests, 1);
2060+
});
2061+
2062+
test(`${failure} finishes the default strategy without items`, async () => {
2063+
const seed = new Note({ contexts: [contextId] });
2064+
deepStrictEqual(await collect({ documentLoader: fail }, seed), []);
2065+
});
2066+
2067+
test(`${failure} consumes the request budget`, async () => {
2068+
const seed = new Note({ contexts: [contextId], replyTarget: parent.id });
2069+
const requests: string[] = [];
2070+
const items = await collect(
2071+
{
2072+
documentLoader: (url) => {
2073+
requests.push(url.href);
2074+
if (url.href === contextId.href) return fail();
2075+
return Promise.resolve(parent);
2076+
},
2077+
},
2078+
seed,
2079+
{ strategies: ["context-auto", "reply-tree"], maxRequests: 1 },
2080+
);
2081+
deepStrictEqual(items, []);
2082+
deepStrictEqual(requests, [contextId.href]);
2083+
});
2084+
2085+
test(`${failure} propagates the cancellation reason`, async () => {
2086+
const controller = new AbortController();
2087+
const reason = new Error("Stop backfill");
2088+
const seed = new Note({ contexts: [contextId], replyTarget: parent });
2089+
const yielded: unknown[] = [];
2090+
await rejects(async () => {
2091+
for await (
2092+
const item of backfill(
2093+
{
2094+
documentLoader: (_url, options) => {
2095+
strictEqual(options?.signal, controller.signal);
2096+
controller.abort(reason);
2097+
return fail();
2098+
},
2099+
},
2100+
seed,
2101+
{
2102+
strategies: ["context-auto", "reply-tree"],
2103+
signal: controller.signal,
2104+
},
2105+
)
2106+
) yielded.push(item);
2107+
}, (error) => error === reason);
2108+
deepStrictEqual(yielded, []);
2109+
});
2110+
}
2111+
2112+
test("a failed context load is not cached for later reply-tree loads", async () => {
2113+
const seed = new Note({ contexts: [contextId], replyTarget: contextId });
2114+
let requests = 0;
2115+
const items = await collect(
2116+
{
2117+
documentLoader: (url) => {
2118+
strictEqual(url.href, contextId.href);
2119+
requests++;
2120+
if (requests === 1) {
2121+
return Promise.reject(
2122+
new Error("Temporary failure"),
2123+
);
2124+
}
2125+
return Promise.resolve(parent);
2126+
},
2127+
},
2128+
seed,
2129+
{ strategies: ["context-auto", "reply-tree"], maxRequests: 2 },
2130+
);
2131+
deepStrictEqual(items.map((item) => item.object.id?.href), [
2132+
parent.id?.href,
2133+
]);
2134+
strictEqual(requests, 2);
2135+
});
2136+
2137+
test("an exhausted budget still allows embedded reply-tree data", async () => {
2138+
const seed = new Note({ contexts: [contextId], replyTarget: parent });
2139+
const items = await collect(
2140+
{
2141+
documentLoader: () => {
2142+
throw new Error("No requests allowed");
2143+
},
2144+
},
2145+
seed,
2146+
{ strategies: ["context-auto", "reply-tree"], maxRequests: 0 },
2147+
);
2148+
deepStrictEqual(items.map((item) => item.object), [parent]);
2149+
});
2150+
2151+
test("interval callback errors propagate before the loader is called", async () => {
2152+
const reason = new Error("Invalid interval configuration");
2153+
let requests = 0;
2154+
const seed = new Note({ contexts: [contextId], replyTarget: parent });
2155+
await rejects(
2156+
collect(
2157+
{
2158+
documentLoader: () => {
2159+
requests++;
2160+
return Promise.resolve(null);
2161+
},
2162+
},
2163+
seed,
2164+
{
2165+
strategies: ["context-auto", "reply-tree"],
2166+
interval: () => {
2167+
throw reason;
2168+
},
2169+
},
2170+
),
2171+
(error) => error === reason,
2172+
);
2173+
strictEqual(requests, 0);
2174+
});
2175+
2176+
test("invalid interval strings still propagate", async () => {
2177+
let requests = 0;
2178+
const seed = new Note({ contexts: [contextId], replyTarget: parent });
2179+
await rejects(collect(
2180+
{
2181+
documentLoader: () => {
2182+
requests++;
2183+
return Promise.resolve(null);
2184+
},
2185+
},
2186+
seed,
2187+
{
2188+
strategies: ["context-auto", "reply-tree"],
2189+
interval: "not a duration",
2190+
},
2191+
));
2192+
strictEqual(requests, 0);
2193+
});
2194+
});

‎packages/backfill/src/backfill.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ async function* getContextStrategyItems(
200200
}> {
201201
const contextId = note.contextIds[0];
202202
if (contextId == null) return;
203-
const collection = await loadObject(context, contextId, options, budget);
203+
const collection = await loadObject(context, contextId, options, budget, {
204+
skipLoaderErrors: true,
205+
});
204206
if (!isCollection(collection)) return;
205207
for await (
206208
const object of getCollectionItems(
@@ -536,7 +538,7 @@ async function* getCollectionItems(
536538
new URL(url),
537539
options,
538540
budget,
539-
true,
541+
{ throwOnBudgetExceeded: true },
540542
);
541543
if (object == null) throw new Error(`Collection page not found: ${url}`);
542544
return {
@@ -621,7 +623,7 @@ async function loadCollectionItemDocument(
621623
iri,
622624
options,
623625
budget,
624-
true,
626+
{ throwOnBudgetExceeded: true },
625627
);
626628
} catch (error) {
627629
if (error instanceof MaxRequestsExceeded) throw error;
@@ -652,7 +654,13 @@ async function loadObject(
652654
iri: URL,
653655
options: BackfillOptions,
654656
budget: RequestBudget,
655-
throwOnBudgetExceeded = false,
657+
{
658+
throwOnBudgetExceeded = false,
659+
skipLoaderErrors = false,
660+
}: {
661+
throwOnBudgetExceeded?: boolean;
662+
skipLoaderErrors?: boolean;
663+
} = {},
656664
): Promise<APObject | null> {
657665
budget.signal?.throwIfAborted();
658666
const cacheKey = iri.href;
@@ -671,14 +679,19 @@ async function loadObject(
671679
budget.signal?.throwIfAborted();
672680

673681
budget.requestCount++;
674-
const document = context.documentLoader(iri, { signal: budget.signal });
675-
budget.documents.set(cacheKey, document);
682+
let document: Promise<APObject | null> | undefined;
676683
try {
684+
document = context.documentLoader(iri, { signal: budget.signal });
685+
budget.documents.set(cacheKey, document);
677686
return await document;
678687
} catch (error) {
679688
if (budget.documents.get(cacheKey) === document) {
680689
budget.documents.delete(cacheKey);
681690
}
691+
if (skipLoaderErrors) {
692+
budget.signal?.throwIfAborted();
693+
return null;
694+
}
682695
throw error;
683696
}
684697
}

0 commit comments

Comments
 (0)