bridge-index-preflight-estimate.test.mjs
208 lines 7.4 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Unit tests for `lib/bridge-index-preflight-estimate.mjs`.
3 *
4 * The estimator is the single decision point that routes a `POST /api/v1/index`
5 * call to either the synchronous path (returns indexed result inline, ~10 s) or
6 * the background path (returns 202 + jobId, runs up to 15 min in a Netlify
7 * background function). A regression here either:
8 * - routes too many jobs to background (slow UX, extra cold start), or
9 * - routes a too-large job to sync (gateway 504 mid-request).
10 *
11 * Both failure modes are exactly the things this PR exists to prevent, so the
12 * routing math gets locked in here.
13 */
14
15 import test from 'node:test';
16 import assert from 'node:assert/strict';
17 import {
18 estimateEmbedSeconds,
19 shouldUseBackgroundIndex,
20 parseSyncBudgetSeconds,
21 parseMaxSyncChunks,
22 DEFAULT_EMBED_MS_PER_BATCH,
23 SYNC_BUDGET_SECONDS_DEFAULT,
24 MAX_SYNC_CHUNKS_DEFAULT,
25 } from '../lib/bridge-index-preflight-estimate.mjs';
26
27 test('estimateEmbedSeconds: zero or negative chunks → 0', () => {
28 assert.strictEqual(estimateEmbedSeconds({ chunksToEmbed: 0, batchSize: 50, concurrency: 5 }), 0);
29 assert.strictEqual(estimateEmbedSeconds({ chunksToEmbed: -10, batchSize: 50, concurrency: 5 }), 0);
30 });
31
32 test('estimateEmbedSeconds: 251 chunks @ 50/batch, concurrency 5, 2.5s/batch', () => {
33 // 251 chunks → 6 batches; 6/5 → ceil = 2 waves; 2 * 2500ms = 5000ms embed.
34 // Upsert: 251 * 5 = 1255ms; fixed: 3000ms; total 9255ms → 10 s.
35 const got = estimateEmbedSeconds({
36 chunksToEmbed: 251,
37 batchSize: 50,
38 concurrency: 5,
39 msPerBatch: 2500,
40 });
41 assert.strictEqual(got, 10);
42 });
43
44 test('estimateEmbedSeconds: 1500 chunks @ 50/batch, concurrency 5 → ~37 s (over budget)', () => {
45 // 1500 / 50 = 30 batches; 30/5 → 6 waves; 6 * 2500 = 15000ms embed.
46 // Upsert: 1500 * 5 = 7500ms; fixed: 3000ms; total 25500ms → 26 s.
47 // Budget 30 s → still fits; but the chunk-count safety net (>=500) kicks in
48 // and routes this to background regardless. estimate is just informational.
49 const got = estimateEmbedSeconds({
50 chunksToEmbed: 1500,
51 batchSize: 50,
52 concurrency: 5,
53 msPerBatch: 2500,
54 });
55 assert.strictEqual(got, 26);
56 });
57
58 test('estimateEmbedSeconds: 3000 chunks @ 50/batch, concurrency 5, 2.5s/batch → exceeds budget', () => {
59 // 3000 / 50 = 60 batches; 60/5 → 12 waves; 12 * 2500 = 30000ms embed.
60 // Upsert: 3000 * 5 = 15000ms; fixed: 3000ms; total 48000ms → 48 s.
61 const got = estimateEmbedSeconds({
62 chunksToEmbed: 3000,
63 batchSize: 50,
64 concurrency: 5,
65 msPerBatch: 2500,
66 });
67 assert.strictEqual(got, 48);
68 });
69
70 test('estimateEmbedSeconds: defaults match documented constants when overrides omitted', () => {
71 // 500 chunks → 10 batches → 2 waves @ DEFAULT_EMBED_MS_PER_BATCH.
72 const explicit = estimateEmbedSeconds({
73 chunksToEmbed: 500,
74 batchSize: 50,
75 concurrency: 5,
76 msPerBatch: DEFAULT_EMBED_MS_PER_BATCH,
77 upsertMsPerChunk: 5,
78 fixedOverheadMs: 3000,
79 });
80 const defaulted = estimateEmbedSeconds({
81 chunksToEmbed: 500,
82 batchSize: 50,
83 concurrency: 5,
84 });
85 assert.strictEqual(defaulted, explicit, 'defaults should be the documented constants');
86 });
87
88 test('estimateEmbedSeconds: throws on invalid batch size / concurrency', () => {
89 assert.throws(
90 () => estimateEmbedSeconds({ chunksToEmbed: 100, batchSize: 0, concurrency: 5 }),
91 /batchSize must be >= 1/,
92 );
93 assert.throws(
94 () => estimateEmbedSeconds({ chunksToEmbed: 100, batchSize: 50, concurrency: 0 }),
95 /concurrency must be >= 1/,
96 );
97 });
98
99 test('shouldUseBackgroundIndex: small job (250 chunks, 10 s) → sync', () => {
100 const got = shouldUseBackgroundIndex({
101 chunksToEmbed: 250,
102 estimatedSeconds: 10,
103 });
104 assert.deepStrictEqual(got, { shouldUseBackground: false, reason: 'fits_in_sync' });
105 });
106
107 test('shouldUseBackgroundIndex: estimate exceeds budget → background', () => {
108 const got = shouldUseBackgroundIndex({
109 chunksToEmbed: 100,
110 estimatedSeconds: 35,
111 });
112 assert.deepStrictEqual(got, {
113 shouldUseBackground: true,
114 reason: 'estimate_exceeds_budget',
115 });
116 });
117
118 test('shouldUseBackgroundIndex: chunk count >= max → background even when estimate is small', () => {
119 // 500 chunks but estimate 20 s — still routed to background by the chunk-count safety.
120 const got = shouldUseBackgroundIndex({
121 chunksToEmbed: 500,
122 estimatedSeconds: 20,
123 });
124 assert.deepStrictEqual(got, {
125 shouldUseBackground: true,
126 reason: 'chunk_count_exceeds_max',
127 });
128 });
129
130 test('shouldUseBackgroundIndex: dim migration required → background even for tiny jobs', () => {
131 const got = shouldUseBackgroundIndex({
132 chunksToEmbed: 10,
133 estimatedSeconds: 5,
134 dimMigrationRequired: true,
135 });
136 assert.deepStrictEqual(got, { shouldUseBackground: true, reason: 'dim_migration' });
137 });
138
139 test('shouldUseBackgroundIndex: first-time index → background even for tiny jobs', () => {
140 const got = shouldUseBackgroundIndex({
141 chunksToEmbed: 10,
142 estimatedSeconds: 5,
143 isFirstIndex: true,
144 });
145 assert.deepStrictEqual(got, { shouldUseBackground: true, reason: 'first_index' });
146 });
147
148 test('shouldUseBackgroundIndex: dim_migration takes priority over first_index when both true', () => {
149 const got = shouldUseBackgroundIndex({
150 chunksToEmbed: 10,
151 estimatedSeconds: 5,
152 dimMigrationRequired: true,
153 isFirstIndex: true,
154 });
155 assert.strictEqual(got.reason, 'dim_migration');
156 });
157
158 test('shouldUseBackgroundIndex: chunksToEmbed === 0 → never background (no work to do)', () => {
159 // Empty diff (cache hit on every chunk) is the happy path: must stay synchronous,
160 // no matter what flags say. Otherwise every cache-hit re-index becomes a 202 + cold start.
161 for (const flags of [
162 { dimMigrationRequired: true },
163 { isFirstIndex: true },
164 { dimMigrationRequired: true, isFirstIndex: true },
165 ]) {
166 const got = shouldUseBackgroundIndex({
167 chunksToEmbed: 0,
168 estimatedSeconds: 0,
169 ...flags,
170 });
171 assert.deepStrictEqual(
172 got,
173 { shouldUseBackground: false, reason: 'fits_in_sync' },
174 `chunksToEmbed=0 with ${JSON.stringify(flags)} must stay sync`,
175 );
176 }
177 });
178
179 test('shouldUseBackgroundIndex: respects custom syncBudgetSeconds + maxSyncChunks', () => {
180 const got = shouldUseBackgroundIndex({
181 chunksToEmbed: 100,
182 estimatedSeconds: 20,
183 syncBudgetSeconds: 15,
184 maxSyncChunks: 200,
185 });
186 assert.deepStrictEqual(got, {
187 shouldUseBackground: true,
188 reason: 'estimate_exceeds_budget',
189 });
190 });
191
192 test('parseSyncBudgetSeconds: defaults, parses, clamps', () => {
193 assert.strictEqual(parseSyncBudgetSeconds(undefined), SYNC_BUDGET_SECONDS_DEFAULT);
194 assert.strictEqual(parseSyncBudgetSeconds(''), SYNC_BUDGET_SECONDS_DEFAULT);
195 assert.strictEqual(parseSyncBudgetSeconds('20'), 20);
196 assert.strictEqual(parseSyncBudgetSeconds('not-a-number'), SYNC_BUDGET_SECONDS_DEFAULT);
197 assert.strictEqual(parseSyncBudgetSeconds('1'), 5, 'floor at 5 s');
198 assert.strictEqual(parseSyncBudgetSeconds('999'), 55, 'ceiling at 55 s (under platform 60 s max)');
199 });
200
201 test('parseMaxSyncChunks: defaults, parses, clamps', () => {
202 assert.strictEqual(parseMaxSyncChunks(undefined), MAX_SYNC_CHUNKS_DEFAULT);
203 assert.strictEqual(parseMaxSyncChunks(''), MAX_SYNC_CHUNKS_DEFAULT);
204 assert.strictEqual(parseMaxSyncChunks('300'), 300);
205 assert.strictEqual(parseMaxSyncChunks('xyz'), MAX_SYNC_CHUNKS_DEFAULT);
206 assert.strictEqual(parseMaxSyncChunks('10'), 50, 'floor at 50');
207 assert.strictEqual(parseMaxSyncChunks('99999'), 5000, 'ceiling at 5000');
208 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago