bridge-index-job-lock.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-job-lock.mjs`. |
| 3 | * |
| 4 | * The lock is the only thing standing between a re-clicked Re-index button and |
| 5 | * a duplicated DeepInfra-billed re-embed of the same vault. Tests cover the |
| 6 | * three states that matter: |
| 7 | * 1. No lock → acquire succeeds with a fresh jobId. |
| 8 | * 2. Live lock (now < expiresAt) → acquire fails, returns existing record. |
| 9 | * 3. Stale lock (now > expiresAt, e.g. background function crashed) → |
| 10 | * acquire silently overwrites; future re-indexes are not blocked forever. |
| 11 | * |
| 12 | * Plus the safety net for `releaseJobLock` with `expectedJobId`: a slow |
| 13 | * finalize-on-success path must not delete a fresher lock that a different |
| 14 | * background job has since acquired. |
| 15 | */ |
| 16 | |
| 17 | import test from 'node:test'; |
| 18 | import assert from 'node:assert/strict'; |
| 19 | import { |
| 20 | acquireJobLock, |
| 21 | releaseJobLock, |
| 22 | peekJobLock, |
| 23 | jobLockKey, |
| 24 | JOB_LOCK_TTL_MS, |
| 25 | } from '../lib/bridge-index-job-lock.mjs'; |
| 26 | |
| 27 | function makeFakeBlobStore() { |
| 28 | const store = new Map(); |
| 29 | return { |
| 30 | _store: store, |
| 31 | async get(key, opts) { |
| 32 | const v = store.get(key); |
| 33 | if (v == null) return null; |
| 34 | if (opts?.type === 'arrayBuffer') { |
| 35 | const buf = Buffer.from(String(v), 'utf8'); |
| 36 | return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); |
| 37 | } |
| 38 | return String(v); |
| 39 | }, |
| 40 | async set(key, value) { |
| 41 | store.set(key, String(value)); |
| 42 | }, |
| 43 | async delete(key) { |
| 44 | store.delete(key); |
| 45 | }, |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | test('jobLockKey: uses canonical path so two callers cannot collide', () => { |
| 50 | assert.strictEqual(jobLockKey('user_1', 'Business'), 'index-jobs/user_1/Business.json'); |
| 51 | }); |
| 52 | |
| 53 | test('jobLockKey: rejects empty canisterUid or vaultId', () => { |
| 54 | assert.throws(() => jobLockKey('', 'v'), /canisterUid must be a non-empty string/); |
| 55 | assert.throws(() => jobLockKey('u', ''), /vaultId must be a non-empty string/); |
| 56 | }); |
| 57 | |
| 58 | test('acquireJobLock: succeeds when no prior lock', async () => { |
| 59 | const store = makeFakeBlobStore(); |
| 60 | const result = await acquireJobLock(store, { |
| 61 | canisterUid: 'user_1', |
| 62 | vaultId: 'Business', |
| 63 | actorUid: 'actor_a', |
| 64 | chunksToEmbed: 1500, |
| 65 | estimatedSeconds: 90, |
| 66 | reason: 'chunk_count_exceeds_max', |
| 67 | now: () => 1_000_000, |
| 68 | randomUUID: () => '11111111-2222-3333-4444-555555555555', |
| 69 | }); |
| 70 | assert.strictEqual(result.acquired, true); |
| 71 | assert.strictEqual(result.jobId, '11111111-2222-3333-4444-555555555555'); |
| 72 | assert.strictEqual(result.record.startedAt, 1_000_000); |
| 73 | assert.strictEqual(result.record.expiresAt, 1_000_000 + JOB_LOCK_TTL_MS); |
| 74 | assert.strictEqual(result.record.chunksToEmbed, 1500); |
| 75 | assert.strictEqual(result.record.reason, 'chunk_count_exceeds_max'); |
| 76 | assert.strictEqual(store._store.size, 1); |
| 77 | }); |
| 78 | |
| 79 | test('acquireJobLock: live lock blocks a second acquire', async () => { |
| 80 | const store = makeFakeBlobStore(); |
| 81 | const first = await acquireJobLock(store, { |
| 82 | canisterUid: 'user_1', |
| 83 | vaultId: 'Business', |
| 84 | now: () => 1_000_000, |
| 85 | randomUUID: () => 'job-1', |
| 86 | }); |
| 87 | assert.strictEqual(first.acquired, true); |
| 88 | |
| 89 | const second = await acquireJobLock(store, { |
| 90 | canisterUid: 'user_1', |
| 91 | vaultId: 'Business', |
| 92 | now: () => 1_000_000 + 60_000, // 1 minute later, well within TTL |
| 93 | randomUUID: () => 'job-2', |
| 94 | }); |
| 95 | assert.strictEqual(second.acquired, false); |
| 96 | assert.ok(second.existing, 'must surface the existing record'); |
| 97 | assert.strictEqual(second.existing.jobId, 'job-1'); |
| 98 | }); |
| 99 | |
| 100 | test('acquireJobLock: stale lock (past TTL) is overwritten', async () => { |
| 101 | const store = makeFakeBlobStore(); |
| 102 | await acquireJobLock(store, { |
| 103 | canisterUid: 'user_1', |
| 104 | vaultId: 'Business', |
| 105 | now: () => 1_000_000, |
| 106 | randomUUID: () => 'crashed-job', |
| 107 | }); |
| 108 | const fresh = await acquireJobLock(store, { |
| 109 | canisterUid: 'user_1', |
| 110 | vaultId: 'Business', |
| 111 | now: () => 1_000_000 + JOB_LOCK_TTL_MS + 1, |
| 112 | randomUUID: () => 'recovery-job', |
| 113 | }); |
| 114 | assert.strictEqual(fresh.acquired, true, 'stale lock must NOT block forever'); |
| 115 | assert.strictEqual(fresh.jobId, 'recovery-job'); |
| 116 | }); |
| 117 | |
| 118 | test('acquireJobLock: distinct (canisterUid, vaultId) pairs do not block each other', async () => { |
| 119 | const store = makeFakeBlobStore(); |
| 120 | const a = await acquireJobLock(store, { |
| 121 | canisterUid: 'user_1', |
| 122 | vaultId: 'Business', |
| 123 | now: () => 1_000_000, |
| 124 | randomUUID: () => 'job-a', |
| 125 | }); |
| 126 | const b = await acquireJobLock(store, { |
| 127 | canisterUid: 'user_1', |
| 128 | vaultId: 'Personal', // different vault |
| 129 | now: () => 1_000_000, |
| 130 | randomUUID: () => 'job-b', |
| 131 | }); |
| 132 | const c = await acquireJobLock(store, { |
| 133 | canisterUid: 'user_2', // different user |
| 134 | vaultId: 'Business', |
| 135 | now: () => 1_000_000, |
| 136 | randomUUID: () => 'job-c', |
| 137 | }); |
| 138 | assert.strictEqual(a.acquired, true); |
| 139 | assert.strictEqual(b.acquired, true); |
| 140 | assert.strictEqual(c.acquired, true); |
| 141 | }); |
| 142 | |
| 143 | test('releaseJobLock: unconditional delete (no expectedJobId)', async () => { |
| 144 | const store = makeFakeBlobStore(); |
| 145 | await acquireJobLock(store, { |
| 146 | canisterUid: 'user_1', |
| 147 | vaultId: 'Business', |
| 148 | now: () => 1_000_000, |
| 149 | }); |
| 150 | const released = await releaseJobLock(store, { |
| 151 | canisterUid: 'user_1', |
| 152 | vaultId: 'Business', |
| 153 | }); |
| 154 | assert.deepStrictEqual(released, { released: true }); |
| 155 | assert.strictEqual(store._store.size, 0); |
| 156 | }); |
| 157 | |
| 158 | test('releaseJobLock: expectedJobId mismatch refuses to delete (protects newer in-flight job)', async () => { |
| 159 | const store = makeFakeBlobStore(); |
| 160 | // Job A acquires, then crashes; Job B acquires a fresh lock after the TTL. |
| 161 | await acquireJobLock(store, { |
| 162 | canisterUid: 'user_1', |
| 163 | vaultId: 'Business', |
| 164 | now: () => 1_000_000, |
| 165 | randomUUID: () => 'job-a', |
| 166 | }); |
| 167 | await acquireJobLock(store, { |
| 168 | canisterUid: 'user_1', |
| 169 | vaultId: 'Business', |
| 170 | now: () => 1_000_000 + JOB_LOCK_TTL_MS + 1, |
| 171 | randomUUID: () => 'job-b', |
| 172 | }); |
| 173 | // Job A's late finalize tries to release "its" lock — must NOT clobber Job B. |
| 174 | const releasedA = await releaseJobLock(store, { |
| 175 | canisterUid: 'user_1', |
| 176 | vaultId: 'Business', |
| 177 | expectedJobId: 'job-a', |
| 178 | }); |
| 179 | assert.deepStrictEqual(releasedA, { released: false, reason: 'lock_owned_by_other_job' }); |
| 180 | assert.strictEqual(store._store.size, 1, 'job-b lock must still be there'); |
| 181 | }); |
| 182 | |
| 183 | test('releaseJobLock: expectedJobId on missing lock returns lock_already_gone', async () => { |
| 184 | const store = makeFakeBlobStore(); |
| 185 | const released = await releaseJobLock(store, { |
| 186 | canisterUid: 'user_1', |
| 187 | vaultId: 'Business', |
| 188 | expectedJobId: 'job-x', |
| 189 | }); |
| 190 | assert.deepStrictEqual(released, { released: false, reason: 'lock_already_gone' }); |
| 191 | }); |
| 192 | |
| 193 | test('peekJobLock: returns the live record without mutation', async () => { |
| 194 | const store = makeFakeBlobStore(); |
| 195 | await acquireJobLock(store, { |
| 196 | canisterUid: 'user_1', |
| 197 | vaultId: 'Business', |
| 198 | chunksToEmbed: 1500, |
| 199 | now: () => 1_000_000, |
| 200 | randomUUID: () => 'peek-job', |
| 201 | }); |
| 202 | const peek1 = await peekJobLock(store, { canisterUid: 'user_1', vaultId: 'Business' }); |
| 203 | const peek2 = await peekJobLock(store, { canisterUid: 'user_1', vaultId: 'Business' }); |
| 204 | assert.strictEqual(peek1.jobId, 'peek-job'); |
| 205 | assert.strictEqual(peek1.chunksToEmbed, 1500); |
| 206 | assert.deepStrictEqual(peek1, peek2, 'peek must be idempotent'); |
| 207 | }); |
| 208 | |
| 209 | test('peekJobLock: returns null when no lock exists', async () => { |
| 210 | const store = makeFakeBlobStore(); |
| 211 | const peek = await peekJobLock(store, { canisterUid: 'user_1', vaultId: 'Business' }); |
| 212 | assert.strictEqual(peek, null); |
| 213 | }); |
| 214 | |
| 215 | test('peekJobLock: returns null on malformed JSON in the blob', async () => { |
| 216 | const store = makeFakeBlobStore(); |
| 217 | await store.set(jobLockKey('user_1', 'Business'), 'not-valid-json{'); |
| 218 | const peek = await peekJobLock(store, { canisterUid: 'user_1', vaultId: 'Business' }); |
| 219 | assert.strictEqual(peek, null); |
| 220 | }); |
| 221 | |
| 222 | test('acquireJobLock: blob get throwing (e.g. transient Blob error) is treated as no lock', async () => { |
| 223 | const erroringStore = { |
| 224 | async get() { |
| 225 | throw new Error('transient blob error'); |
| 226 | }, |
| 227 | async set() { |
| 228 | // accept |
| 229 | }, |
| 230 | async delete() {}, |
| 231 | }; |
| 232 | const got = await acquireJobLock(erroringStore, { |
| 233 | canisterUid: 'user_1', |
| 234 | vaultId: 'Business', |
| 235 | now: () => 1_000_000, |
| 236 | randomUUID: () => 'recovered-job', |
| 237 | }); |
| 238 | assert.strictEqual(got.acquired, true, 'transient read failure must not block re-index forever'); |
| 239 | }); |
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
1 day ago