|
1 | | -import { describe, it, expect, vi } from 'vitest'; |
| 1 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
2 | 2 | import { PineconeIndexSession } from './indexes.js'; |
3 | 3 | import type { SearchableIndex } from '../../types.js'; |
4 | 4 |
|
@@ -29,6 +29,60 @@ class ThrowingEnsureSession extends PineconeIndexSession { |
29 | 29 | } |
30 | 30 | } |
31 | 31 |
|
| 32 | +/** Session with a short timeout for fake-timer I/O deadline tests. */ |
| 33 | +class TimeoutIndexSession extends PineconeIndexSession { |
| 34 | + constructor( |
| 35 | + private readonly pair: { dense: SearchableIndex; sparse: SearchableIndex }, |
| 36 | + requestTimeoutMs = 50 |
| 37 | + ) { |
| 38 | + super('test-api-key', 'test-index', undefined, requestTimeoutMs); |
| 39 | + } |
| 40 | + |
| 41 | + override async ensureIndexes(): Promise<{ |
| 42 | + denseIndex: SearchableIndex; |
| 43 | + sparseIndex: SearchableIndex; |
| 44 | + }> { |
| 45 | + return { denseIndex: this.pair.dense, sparseIndex: this.pair.sparse }; |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +afterEach(() => { |
| 50 | + vi.useRealTimers(); |
| 51 | +}); |
| 52 | + |
| 53 | +/** Mock describeIndexStats that fails when the SDK method is invoked detached. */ |
| 54 | +function makeThisSensitiveIndex( |
| 55 | + result: { |
| 56 | + namespaces?: Record<string, { recordCount?: number }>; |
| 57 | + dimension?: number; |
| 58 | + } = {} |
| 59 | +): SearchableIndex { |
| 60 | + const index = { |
| 61 | + describeIndexStats() { |
| 62 | + if (this !== index) { |
| 63 | + throw new TypeError('detached SDK method: wrong this binding'); |
| 64 | + } |
| 65 | + return Promise.resolve(result); |
| 66 | + }, |
| 67 | + }; |
| 68 | + return index as unknown as SearchableIndex; |
| 69 | +} |
| 70 | + |
| 71 | +/** Mock namespace().query that fails when the SDK method is invoked detached. */ |
| 72 | +function makeThisSensitiveNamespaceHandle( |
| 73 | + matches: Array<{ metadata?: Record<string, unknown> }> = [] |
| 74 | +) { |
| 75 | + const handle = { |
| 76 | + query() { |
| 77 | + if (this !== handle) { |
| 78 | + throw new TypeError('detached SDK method: wrong this binding'); |
| 79 | + } |
| 80 | + return Promise.resolve({ matches }); |
| 81 | + }, |
| 82 | + }; |
| 83 | + return handle; |
| 84 | +} |
| 85 | + |
32 | 86 | describe('PineconeIndexSession', () => { |
33 | 87 | describe('listNamespacesFromKeywordIndex', () => { |
34 | 88 | it('returns namespace rows when describeIndexStats succeeds', async () => { |
@@ -66,6 +120,63 @@ describe('PineconeIndexSession', () => { |
66 | 120 | expect(result.error).toContain('stats unavailable'); |
67 | 121 | } |
68 | 122 | }); |
| 123 | + |
| 124 | + it('retries describeIndexStats on 503 then succeeds', async () => { |
| 125 | + let n = 0; |
| 126 | + const sparse = { |
| 127 | + describeIndexStats: vi.fn().mockImplementation(async () => { |
| 128 | + n++; |
| 129 | + if (n < 2) throw new Error('HTTP 503'); |
| 130 | + return { namespaces: { papers: { recordCount: 7 } } }; |
| 131 | + }), |
| 132 | + } as unknown as SearchableIndex; |
| 133 | + const session = new PineconeIndexSessionTestDouble({ |
| 134 | + dense: {} as SearchableIndex, |
| 135 | + sparse, |
| 136 | + }); |
| 137 | + |
| 138 | + const result = await session.listNamespacesFromKeywordIndex(); |
| 139 | + |
| 140 | + expect(result.ok).toBe(true); |
| 141 | + if (result.ok) { |
| 142 | + expect(result.namespaces).toEqual([{ namespace: 'papers', recordCount: 7 }]); |
| 143 | + } |
| 144 | + expect(sparse.describeIndexStats).toHaveBeenCalledTimes(2); |
| 145 | + }); |
| 146 | + |
| 147 | + it('invokes describeIndexStats on the sparse index receiver through runIo', async () => { |
| 148 | + const sparse = makeThisSensitiveIndex({ |
| 149 | + namespaces: { papers: { recordCount: 42 } }, |
| 150 | + }); |
| 151 | + const session = new PineconeIndexSessionTestDouble({ |
| 152 | + dense: {} as SearchableIndex, |
| 153 | + sparse, |
| 154 | + }); |
| 155 | + |
| 156 | + const result = await session.listNamespacesFromKeywordIndex(); |
| 157 | + |
| 158 | + expect(result.ok).toBe(true); |
| 159 | + if (result.ok) { |
| 160 | + expect(result.namespaces).toEqual([{ namespace: 'papers', recordCount: 42 }]); |
| 161 | + } |
| 162 | + }); |
| 163 | + |
| 164 | + it('times out describeIndexStats at requestTimeoutMs', async () => { |
| 165 | + vi.useFakeTimers(); |
| 166 | + const sparse = { |
| 167 | + describeIndexStats: vi.fn(() => new Promise(() => {})), |
| 168 | + } as unknown as SearchableIndex; |
| 169 | + const session = new TimeoutIndexSession({ dense: {} as SearchableIndex, sparse }); |
| 170 | + const p = session.listNamespacesFromKeywordIndex(); |
| 171 | + const assertion = expect(p).resolves.toMatchObject({ |
| 172 | + ok: false, |
| 173 | + error: expect.stringMatching( |
| 174 | + /Timeout after 50ms while waiting for describeIndexStats-sparse/ |
| 175 | + ), |
| 176 | + }); |
| 177 | + await vi.advanceTimersByTimeAsync(50); |
| 178 | + await assertion; |
| 179 | + }); |
69 | 180 | }); |
70 | 181 |
|
71 | 182 | describe('listNamespacesWithMetadata', () => { |
@@ -105,6 +216,28 @@ describe('PineconeIndexSession', () => { |
105 | 216 | }); |
106 | 217 | }); |
107 | 218 |
|
| 219 | + it('invokes namespace().query on the namespace receiver through runIo when sampling', async () => { |
| 220 | + const nsHandle = makeThisSensitiveNamespaceHandle([ |
| 221 | + { metadata: { title: 'T', tags: ['a'] } }, |
| 222 | + ]); |
| 223 | + const dense = makeThisSensitiveIndex({ |
| 224 | + namespaces: { ns1: { recordCount: 2 } }, |
| 225 | + dimension: 4, |
| 226 | + }) as SearchableIndex & { namespace: (name: string) => typeof nsHandle }; |
| 227 | + dense.namespace = () => nsHandle; |
| 228 | + const session = new PineconeIndexSessionTestDouble({ |
| 229 | + dense, |
| 230 | + sparse: {} as SearchableIndex, |
| 231 | + }); |
| 232 | + |
| 233 | + const rows = await session.listNamespacesWithMetadata(); |
| 234 | + expect(rows.namespaces).toHaveLength(1); |
| 235 | + expect(rows.namespaces[0]?.metadata).toMatchObject({ |
| 236 | + title: 'string', |
| 237 | + tags: 'string[]', |
| 238 | + }); |
| 239 | + }); |
| 240 | + |
108 | 241 | it('samples metadata when records exist and namespace.query returns matches', async () => { |
109 | 242 | const dense = { |
110 | 243 | describeIndexStats: vi.fn().mockResolvedValue({ |
@@ -141,6 +274,33 @@ describe('PineconeIndexSession', () => { |
141 | 274 | expect(rows.namespaces[0]?.schema_source).toBe('sampled'); |
142 | 275 | }); |
143 | 276 |
|
| 277 | + it('times out metadata sampling at requestTimeoutMs', async () => { |
| 278 | + vi.useFakeTimers(); |
| 279 | + const dense = { |
| 280 | + describeIndexStats: vi.fn().mockResolvedValue({ |
| 281 | + namespaces: { ns1: { recordCount: 2 } }, |
| 282 | + dimension: 4, |
| 283 | + }), |
| 284 | + namespace: vi.fn().mockReturnValue({ |
| 285 | + query: vi.fn(() => new Promise(() => {})), |
| 286 | + }), |
| 287 | + } as unknown as SearchableIndex; |
| 288 | + const session = new TimeoutIndexSession({ dense, sparse: {} as SearchableIndex }); |
| 289 | + const p = session.listNamespacesWithMetadata(); |
| 290 | + const assertion = expect(p).resolves.toMatchObject({ |
| 291 | + namespaces: [ |
| 292 | + { |
| 293 | + namespace: 'ns1', |
| 294 | + recordCount: 2, |
| 295 | + metadata: {}, |
| 296 | + schema_source: 'sampled', |
| 297 | + }, |
| 298 | + ], |
| 299 | + }); |
| 300 | + await vi.advanceTimersByTimeAsync(50); |
| 301 | + await assertion; |
| 302 | + }); |
| 303 | + |
144 | 304 | it('uses declared schema and skips sampling when declaredSchemas provides one', async () => { |
145 | 305 | const query = vi.fn(); |
146 | 306 | const dense = { |
@@ -235,6 +395,16 @@ describe('PineconeIndexSession', () => { |
235 | 395 | }); |
236 | 396 |
|
237 | 397 | describe('checkIndexes', () => { |
| 398 | + it('invokes describeIndexStats on dense and sparse receivers through runIo', async () => { |
| 399 | + const dense = makeThisSensitiveIndex(); |
| 400 | + const sparse = makeThisSensitiveIndex(); |
| 401 | + const session = new PineconeIndexSessionTestDouble({ dense, sparse }); |
| 402 | + |
| 403 | + const result = await session.checkIndexes(); |
| 404 | + expect(result.ok).toBe(true); |
| 405 | + expect(result.errors).toHaveLength(0); |
| 406 | + }); |
| 407 | + |
238 | 408 | it('returns ok when describeIndexStats succeeds for dense and sparse', async () => { |
239 | 409 | const dense = { |
240 | 410 | describeIndexStats: vi.fn().mockResolvedValue({}), |
@@ -348,5 +518,31 @@ describe('PineconeIndexSession', () => { |
348 | 518 | /Timeout after 50ms while waiting for fetchRecordFields/ |
349 | 519 | ); |
350 | 520 | }); |
| 521 | + |
| 522 | + it('retries fetch on 503 then succeeds', async () => { |
| 523 | + let n = 0; |
| 524 | + class RetryFetchSession extends PineconeIndexSession { |
| 525 | + override ensureClient() { |
| 526 | + return { |
| 527 | + index: () => ({ |
| 528 | + fetch: vi.fn().mockImplementation(async () => { |
| 529 | + n++; |
| 530 | + if (n < 2) throw new Error('HTTP 503'); |
| 531 | + return { |
| 532 | + records: { |
| 533 | + schema_manifest: { metadata: { chunk_text: '{"ok":true}' } }, |
| 534 | + }, |
| 535 | + }; |
| 536 | + }), |
| 537 | + }), |
| 538 | + } as never; |
| 539 | + } |
| 540 | + } |
| 541 | + |
| 542 | + const session = new RetryFetchSession(); |
| 543 | + const fields = await session.fetchRecordFields('_mcp_config', 'schema_manifest'); |
| 544 | + expect(fields?.chunk_text).toBe('{"ok":true}'); |
| 545 | + expect(n).toBe(2); |
| 546 | + }); |
351 | 547 | }); |
352 | 548 | }); |
0 commit comments