bridge-index-preflight-estimate.test.mjs
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