Skip to content

Commit 63c47eb

Browse files
anthony-murphy-agentanthony-murphyclaude
authored
fix(driver-utils): address PR #26151 review comments (#26204)
## Summary - Replace `any` with proper error types in tests using `GenericNetworkError` and `NonRetryableError` - Add test for non-retryable errors to verify cache behavior - Skip prefetch calls if blobId is already cached to avoid unnecessary async overhead This is a follow-up PR to address review comments from #26151: - @jason-ha: Avoid `any` and `as any` - errors should use proper types - @jason-ha: Add test for non-retryable errors - @markfields: Skip `readBlob` call if blobId is already in `prefetchCache` ## Test plan - [x] New unit test for non-retryable error caching behavior - [x] All existing prefetch tests pass (6 total) - [x] Lint passes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: anthony-murphy <anthony.murphy@microsoft.com> --------- Co-authored-by: anthony-murphy-agent <anthony-murphy-agent@users.noreply.github.com> Co-authored-by: anthony-murphy <anthony.murphy@microsoft.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fe15b4a commit 63c47eb

File tree

2 files changed

+125
-44
lines changed

2 files changed

+125
-44
lines changed

packages/loader/driver-utils/src/prefetchDocumentStorageService.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export class PrefetchDocumentStorageService extends DocumentStorageServiceProxy
8686
this.prefetchTreeCore(tree, secondary);
8787

8888
for (const blob of secondary) {
89+
// Skip if already cached (avoids unnecessary async overhead)
90+
if (this.prefetchCache.has(blob)) {
91+
continue;
92+
}
8993
// Fire-and-forget prefetch. The .catch() prevents unhandled rejection
9094
// since cachedRead is async and returns a separate promise chain.
9195
this.cachedRead(blob).catch(() => {});
@@ -95,7 +99,8 @@ export class PrefetchDocumentStorageService extends DocumentStorageServiceProxy
9599
private prefetchTreeCore(tree: ISnapshotTree, secondary: string[]): void {
96100
for (const [blobKey, blob] of Object.entries(tree.blobs)) {
97101
if (blobKey.startsWith(".") || blobKey === "header" || blobKey.startsWith("quorum")) {
98-
if (blob !== null) {
102+
// Skip if already cached (avoids unnecessary async overhead)
103+
if (blob !== null && !this.prefetchCache.has(blob)) {
99104
// Fire-and-forget prefetch. The .catch() prevents unhandled rejection
100105
// since cachedRead is async and returns a separate promise chain.
101106
this.cachedRead(blob).catch(() => {});

packages/loader/driver-utils/src/test/prefetchDocumentStorageService.spec.ts

Lines changed: 119 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,23 @@ import type {
1111
ISnapshotTree,
1212
} from "@fluidframework/driver-definitions/internal";
1313

14+
import { GenericNetworkError, NonRetryableError } from "../network.js";
1415
import { PrefetchDocumentStorageService } from "../prefetchDocumentStorageService.js";
1516

17+
/**
18+
* Creates a retryable error for testing
19+
*/
20+
function createRetryableError(message: string): GenericNetworkError {
21+
return new GenericNetworkError(message, true, { driverVersion: undefined });
22+
}
23+
24+
/**
25+
* Creates a non-retryable error for testing
26+
*/
27+
function createNonRetryableError(message: string): NonRetryableError<"genericNetworkError"> {
28+
return new NonRetryableError(message, "genericNetworkError", { driverVersion: undefined });
29+
}
30+
1631
/**
1732
* Helper to wait for a condition with timeout
1833
*/
@@ -36,9 +51,9 @@ async function waitForCondition(
3651
class MockStorageService implements Partial<IDocumentStorageService> {
3752
public readBlobCalls: string[] = [];
3853
public shouldFail = false;
39-
public failureError = new Error("Mock read failure");
54+
public failureError: Error = new Error("Mock read failure");
4055
public shouldGetSnapshotTreeFail = false;
41-
public getSnapshotTreeError = new Error("Mock getSnapshotTree failure");
56+
public getSnapshotTreeError: Error = new Error("Mock getSnapshotTree failure");
4257

4358
public async readBlob(blobId: string): Promise<ArrayBufferLike> {
4459
this.readBlobCalls.push(blobId);
@@ -96,9 +111,7 @@ describe("PrefetchDocumentStorageService", () => {
96111
});
97112

98113
it("should clear cache on retryable errors allowing retry", async () => {
99-
const retryableError = new Error("Retryable error");
100-
(retryableError as any).canRetry = true;
101-
mockStorage.failureError = retryableError;
114+
mockStorage.failureError = createRetryableError("Retryable error");
102115
mockStorage.shouldFail = true;
103116

104117
// First call fails
@@ -118,6 +131,36 @@ describe("PrefetchDocumentStorageService", () => {
118131
);
119132
});
120133

134+
it("should NOT clear cache on non-retryable errors", async () => {
135+
const nonRetryableError = createNonRetryableError("Non-retryable error");
136+
mockStorage.failureError = nonRetryableError;
137+
mockStorage.shouldFail = true;
138+
139+
// First call fails with non-retryable error
140+
await assert.rejects(
141+
async () => prefetchService.readBlob("blob1"),
142+
(error: Error) => error === nonRetryableError,
143+
"First call should receive the non-retryable error",
144+
);
145+
146+
// Reset mock to return different data (to prove we're using cached rejection)
147+
mockStorage.shouldFail = false;
148+
mockStorage.readBlobCalls = [];
149+
150+
// Second call should still fail with same cached non-retryable error
151+
// (cache should NOT be cleared for non-retryable errors)
152+
await assert.rejects(
153+
async () => prefetchService.readBlob("blob1"),
154+
(error: Error) => error === nonRetryableError,
155+
"Should receive the same cached non-retryable error",
156+
);
157+
assert.strictEqual(
158+
mockStorage.readBlobCalls.length,
159+
0,
160+
"Should not perform new underlying read - cached rejection should be returned",
161+
);
162+
});
163+
121164
it("should successfully prefetch blobs", async () => {
122165
// Trigger prefetch
123166
await prefetchService.getSnapshotTree();
@@ -140,52 +183,85 @@ describe("PrefetchDocumentStorageService", () => {
140183
});
141184

142185
it("should not cause unhandled rejections on fire-and-forget prefetch failures", async () => {
143-
// Set up to fail all blob reads
144-
const prefetchError = new Error("Prefetch network failure");
145-
(prefetchError as any).canRetry = true;
146-
mockStorage.failureError = prefetchError;
147-
mockStorage.shouldFail = true;
186+
// Track unhandled rejections to verify none occur
187+
const unhandledRejections: unknown[] = [];
188+
const rejectionHandler = (reason: unknown): void => {
189+
unhandledRejections.push(reason);
190+
};
191+
process.on("unhandledRejection", rejectionHandler);
148192

149-
// Trigger prefetch via getSnapshotTree (fire-and-forget pattern)
150-
// The prefetch will fail, but should NOT cause unhandled rejection
151-
await prefetchService.getSnapshotTree();
193+
try {
194+
// Set up to fail all blob reads with a non-retryable error
195+
// Using non-retryable so the error is cached and we can verify error identity
196+
const prefetchError = createNonRetryableError("Prefetch network failure");
197+
mockStorage.failureError = prefetchError;
198+
mockStorage.shouldFail = true;
152199

153-
// Wait for prefetch attempts to occur
154-
await waitForCondition(() => mockStorage.readBlobCalls.length > 0);
200+
// Trigger prefetch via getSnapshotTree (fire-and-forget pattern)
201+
// The prefetch will fail, but should NOT cause unhandled rejection
202+
await prefetchService.getSnapshotTree();
155203

156-
// Allow microtask queue to flush (for catch handlers to execute)
157-
await Promise.resolve();
204+
// Wait for prefetch attempts to occur
205+
await waitForCondition(() => mockStorage.readBlobCalls.length > 0);
158206

159-
// If we reach here without unhandled rejection, the test passes
160-
// Now verify that explicit readBlob calls still receive the error properly
161-
mockStorage.readBlobCalls = [];
162-
await assert.rejects(
163-
async () => prefetchService.readBlob("blob1"),
164-
(error: Error) => error.message === "Prefetch network failure",
165-
"Explicit readBlob should still receive the error",
166-
);
207+
// Allow microtask queue to flush (for catch handlers to execute)
208+
await Promise.resolve();
209+
await new Promise((resolve) => setTimeout(resolve, 10));
210+
211+
// Verify no unhandled rejections occurred
212+
assert.strictEqual(
213+
unhandledRejections.length,
214+
0,
215+
`Expected no unhandled rejections, but got: ${JSON.stringify(unhandledRejections)}`,
216+
);
217+
218+
// Also verify that explicit readBlob calls still receive the same cached error
219+
// (non-retryable errors remain cached, so we can verify error identity)
220+
await assert.rejects(
221+
async () => prefetchService.readBlob("blob1"),
222+
(error: Error) => error === prefetchError,
223+
"Explicit readBlob should receive the same cached error instance",
224+
);
225+
} finally {
226+
process.off("unhandledRejection", rejectionHandler);
227+
}
167228
});
168229

169230
it("should not cause unhandled rejections when getSnapshotTree fails", async () => {
170-
// Set up getSnapshotTree to fail (e.g., network timeout)
171-
const networkError = new Error("Socket timeout");
172-
mockStorage.shouldGetSnapshotTreeFail = true;
173-
mockStorage.getSnapshotTreeError = networkError;
174-
175-
// getSnapshotTree internally does: void p.then(...).catch(...)
176-
// Without the .catch(), if p rejects, the derived promise from .then() also
177-
// rejects and causes an unhandled rejection. This test verifies the fix.
178-
await assert.rejects(
179-
async () => prefetchService.getSnapshotTree(),
180-
(error: Error) => error.message === "Socket timeout",
181-
"Caller should receive the error",
182-
);
231+
// Track unhandled rejections to verify none occur
232+
const unhandledRejections: unknown[] = [];
233+
const rejectionHandler = (reason: unknown): void => {
234+
unhandledRejections.push(reason);
235+
};
236+
process.on("unhandledRejection", rejectionHandler);
237+
238+
try {
239+
// Set up getSnapshotTree to fail (e.g., network timeout)
240+
const networkError = new Error("Socket timeout");
241+
mockStorage.shouldGetSnapshotTreeFail = true;
242+
mockStorage.getSnapshotTreeError = networkError;
183243

184-
// Allow microtask queue to flush - if there's an unhandled rejection,
185-
// it would surface here or cause the test to fail
186-
await Promise.resolve();
187-
await new Promise((resolve) => setTimeout(resolve, 10));
244+
// getSnapshotTree internally does: void p.then(...).catch(...)
245+
// Without the .catch(), if p rejects, the derived promise from .then() also
246+
// rejects and causes an unhandled rejection. This test verifies the fix.
247+
await assert.rejects(
248+
async () => prefetchService.getSnapshotTree(),
249+
(error: Error) => error.message === "Socket timeout",
250+
"Caller should receive the error",
251+
);
188252

189-
// If we reach here without unhandled rejection, the test passes
253+
// Allow microtask queue to flush
254+
await Promise.resolve();
255+
await new Promise((resolve) => setTimeout(resolve, 10));
256+
257+
// Verify no unhandled rejections occurred
258+
assert.strictEqual(
259+
unhandledRejections.length,
260+
0,
261+
`Expected no unhandled rejections, but got: ${JSON.stringify(unhandledRejections)}`,
262+
);
263+
} finally {
264+
process.off("unhandledRejection", rejectionHandler);
265+
}
190266
});
191267
});

0 commit comments

Comments
 (0)