Skip to content

Commit 53064f1

Browse files
authored
Merge pull request #297 from tharropoulos/abort-multi
feat(multisearch): Add AbortSignal Support to MultiSearch Operations
2 parents 0f1d20d + 24e1fb4 commit 53064f1

2 files changed

Lines changed: 258 additions & 10 deletions

File tree

src/Typesense/MultiSearch.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import ApiCall from "./ApiCall";
2-
import Configuration from "./Configuration";
1+
import type ApiCall from "./ApiCall";
2+
import type Configuration from "./Configuration";
33
import RequestWithCache from "./RequestWithCache";
4-
import {
4+
import type {
55
DocumentSchema,
66
ExtractBaseTypes,
7+
SearchOptions,
78
SearchParams,
89
SearchResponse,
910
} from "./Documents";
@@ -41,7 +42,7 @@ export default class MultiSearch {
4142
>(
4243
searchRequests: MultiSearchRequestsWithUnionSchema<T[number], Infix>,
4344
commonParams?: MultiSearchUnionParameters<T[number], Infix>,
44-
options?: { cacheSearchResultsForSeconds?: number },
45+
options?: SearchOptions,
4546
): Promise<UnionSearchResponse<T[number]>>;
4647

4748
async perform<
@@ -50,7 +51,7 @@ export default class MultiSearch {
5051
>(
5152
searchRequests: MultiSearchRequestsWithoutUnionSchema<T[number], Infix>,
5253
commonParams?: MultiSearchResultsParameters<T, Infix>,
53-
options?: { cacheSearchResultsForSeconds?: number },
54+
options?: SearchOptions,
5455
): Promise<{
5556
results: { [Index in keyof T]: SearchResponse<T[Index]> } & {
5657
length: T["length"];
@@ -65,10 +66,7 @@ export default class MultiSearch {
6566
commonParams?:
6667
| MultiSearchUnionParameters<T[number], Infix>
6768
| MultiSearchResultsParameters<T, Infix>,
68-
{
69-
cacheSearchResultsForSeconds = this.configuration
70-
.cacheSearchResultsForSeconds,
71-
}: { cacheSearchResultsForSeconds?: number } = {},
69+
options?: SearchOptions,
7270
): Promise<MultiSearchResponse<T, Infix>> {
7371
const params = commonParams ? { ...commonParams } : {};
7472

@@ -105,9 +103,10 @@ export default class MultiSearch {
105103
? { "content-type": "text/plain" }
106104
: {},
107105
streamConfig,
106+
abortSignal: options?.abortSignal,
108107
isStreamingRequest: this.isStreamingRequest(params),
109108
},
110-
{ cacheResponseForSeconds: cacheSearchResultsForSeconds },
109+
{ cacheResponseForSeconds: options?.cacheSearchResultsForSeconds },
111110
);
112111
}
113112

test/multisearch-abort.spec.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { Client as TypesenseClient } from "../src/Typesense";
3+
4+
const { mockAxios, mockCancelFn } = vi.hoisted(() => {
5+
const mockCancelFn = vi.fn();
6+
const mockCancelSource = {
7+
token: "mock-cancel-token",
8+
cancel: mockCancelFn,
9+
};
10+
const mockAxios = vi.fn() as any;
11+
mockAxios.CancelToken = {
12+
source: vi.fn(() => mockCancelSource),
13+
};
14+
15+
return { mockAxios, mockCancelFn };
16+
});
17+
18+
vi.mock("axios", () => ({
19+
default: mockAxios,
20+
__esModule: true,
21+
}));
22+
23+
describe("MultiSearch Abort Signal Tests", () => {
24+
let typesense: TypesenseClient;
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
mockCancelFn.mockClear();
29+
30+
typesense = new TypesenseClient({
31+
nodes: [
32+
{
33+
host: "node0",
34+
port: 8108,
35+
protocol: "http",
36+
},
37+
],
38+
apiKey: "abcd",
39+
randomizeNodes: false,
40+
});
41+
42+
mockAxios.mockResolvedValue({
43+
status: 200,
44+
data: { results: [{ hits: [] }] },
45+
});
46+
});
47+
48+
afterEach(() => {
49+
vi.resetAllMocks();
50+
});
51+
52+
it("should reject immediately if AbortSignal is already aborted", async () => {
53+
const abortController = new AbortController();
54+
abortController.abort(); // Abort immediately
55+
56+
const searchRequests = {
57+
searches: [{ q: "test query", collection: "documents" }],
58+
};
59+
60+
await expect(
61+
typesense.multiSearch.perform(
62+
searchRequests,
63+
{},
64+
{
65+
abortSignal: abortController.signal,
66+
},
67+
),
68+
).rejects.toThrow("Request aborted by caller.");
69+
70+
// Verify axios was never called since request was aborted immediately
71+
expect(mockAxios).not.toHaveBeenCalled();
72+
});
73+
74+
it("should create cancel token when AbortSignal is provided", async () => {
75+
const abortController = new AbortController();
76+
77+
const searchRequests = {
78+
searches: [{ q: "test query", collection: "documents" }],
79+
};
80+
81+
await typesense.multiSearch.perform(
82+
searchRequests,
83+
{},
84+
{
85+
abortSignal: abortController.signal,
86+
},
87+
);
88+
89+
expect(mockAxios.CancelToken.source).toHaveBeenCalled();
90+
91+
expect(mockAxios).toHaveBeenCalledWith(
92+
expect.objectContaining({
93+
cancelToken: "mock-cancel-token",
94+
}),
95+
);
96+
});
97+
98+
it("should add abort event listener", async () => {
99+
const abortController = new AbortController();
100+
const addEventListenerSpy = vi.spyOn(
101+
abortController.signal,
102+
"addEventListener",
103+
);
104+
105+
const searchRequests = {
106+
searches: [{ q: "test query", collection: "documents" }],
107+
};
108+
109+
await typesense.multiSearch.perform(
110+
searchRequests,
111+
{},
112+
{
113+
abortSignal: abortController.signal,
114+
},
115+
);
116+
117+
expect(addEventListenerSpy).toHaveBeenCalledWith(
118+
"abort",
119+
expect.any(Function),
120+
);
121+
});
122+
123+
it("should remove abort event listener after completion", async () => {
124+
const abortController = new AbortController();
125+
const removeEventListenerSpy = vi.spyOn(
126+
abortController.signal,
127+
"removeEventListener",
128+
);
129+
130+
const searchRequests = {
131+
searches: [{ q: "test query", collection: "documents" }],
132+
};
133+
134+
await typesense.multiSearch.perform(
135+
searchRequests,
136+
{},
137+
{
138+
abortSignal: abortController.signal,
139+
},
140+
);
141+
142+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
143+
"abort",
144+
expect.any(Function),
145+
);
146+
});
147+
148+
it("should work correctly without abort signal", async () => {
149+
const searchRequests = {
150+
searches: [{ q: "test query", collection: "documents" }],
151+
};
152+
153+
const result = await typesense.multiSearch.perform(searchRequests, {});
154+
155+
expect(mockAxios.CancelToken.source).not.toHaveBeenCalled();
156+
157+
expect(mockAxios).toHaveBeenCalledWith(
158+
expect.not.objectContaining({
159+
cancelToken: expect.anything(),
160+
}),
161+
);
162+
163+
expect(result).toEqual({ results: [{ hits: [] }] });
164+
});
165+
166+
it("should handle streaming requests with abort signal", async () => {
167+
const abortController = new AbortController();
168+
169+
const mockStream = {
170+
on: vi.fn((event, callback) => {
171+
if (event === "data") {
172+
setTimeout(() => callback('data: {"hits":[]}\n\n'), 1);
173+
}
174+
if (event === "end") {
175+
setTimeout(() => callback(), 2);
176+
}
177+
return mockStream;
178+
}),
179+
pipe: vi.fn(() => mockStream),
180+
};
181+
182+
mockAxios.mockResolvedValueOnce({
183+
status: 200,
184+
data: mockStream,
185+
});
186+
187+
const searchRequests = {
188+
searches: [{ q: "test query", collection: "documents" }],
189+
};
190+
191+
const commonParams = {
192+
conversation: true,
193+
conversation_stream: true,
194+
streamConfig: {
195+
onChunk: vi.fn(),
196+
onComplete: vi.fn(),
197+
onError: vi.fn(),
198+
},
199+
};
200+
201+
const result = await typesense.multiSearch.perform(
202+
searchRequests,
203+
commonParams,
204+
{ abortSignal: abortController.signal },
205+
);
206+
207+
expect(mockAxios.CancelToken.source).toHaveBeenCalled();
208+
209+
expect(mockAxios).toHaveBeenCalledWith(
210+
expect.objectContaining({
211+
responseType: "stream",
212+
cancelToken: "mock-cancel-token",
213+
}),
214+
);
215+
216+
expect(result).toBeDefined();
217+
});
218+
219+
it("should handle error responses with abort signal", async () => {
220+
const abortController = new AbortController();
221+
222+
mockAxios.mockResolvedValueOnce({
223+
status: 400,
224+
data: { message: "Bad request" },
225+
});
226+
227+
const searchRequests = {
228+
searches: [{ q: "test query", collection: "documents" }],
229+
};
230+
231+
await expect(
232+
typesense.multiSearch.perform(
233+
searchRequests,
234+
{},
235+
{
236+
abortSignal: abortController.signal,
237+
},
238+
),
239+
).rejects.toThrow();
240+
241+
expect(mockAxios.CancelToken.source).toHaveBeenCalled();
242+
243+
expect(mockAxios).toHaveBeenCalledWith(
244+
expect.objectContaining({
245+
cancelToken: "mock-cancel-token",
246+
}),
247+
);
248+
});
249+
});

0 commit comments

Comments
 (0)