bridge-index-job-lock.test.mjs
239 lines 8.1 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-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