| … | … | @@ -218,3 +218,89 @@ test('reindexOverlay re-registers blob files after index loss, without inventing |
| 218 | 218 | |
| 219 | 219 | await blobs.close() |
| 220 | 220 | }) |
| 221 | |
| 222 | test('reconcile PULLS blobs a co-owner announced but this node lacks', async (t) => { |
| 223 | const net = new Map(); const shared = new Map() |
| 224 | |
| 225 | // Node A uploads two blobs — bytes enter the shared transport, pointers enter the shared state. |
| 226 | const a = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net)) |
| 227 | const { cid: c1 } = await a.put(BYTES, MIME) |
| 228 | const { cid: c2 } = await a.put(Buffer.from('second blob bytes'), 'image/png') |
| 229 | |
| 230 | // Node B has the same replicated state, but none of the bytes. |
| 231 | const { blobs: bBlobs } = await mkNodeAt(t, net) |
| 232 | const b = new BlobPointerStore(mkStatebase(shared), bBlobs) |
| 233 | t.absent(bBlobs.hasLocalFile(c1.toString()), 'precondition: B holds no bytes for blob 1') |
| 234 | t.absent(bBlobs.hasLocalFile(c2.toString()), 'precondition: B holds no bytes for blob 2') |
| 235 | |
| 236 | const r = await b.reconcile() |
| 237 | t.is(r.checked, 2, 'both announced blobs were checked') |
| 238 | t.is(r.missing, 2, 'both were missing locally') |
| 239 | t.is(r.fetched, 2, 'both were PULLED — this is what never happened before') |
| 240 | t.ok(bBlobs.hasLocalFile(c1.toString()) && bBlobs.hasLocalFile(c2.toString()), 'B now holds the bytes on disk') |
| 241 | t.ok(Buffer.from((await b.get(c1.toString())).bytes).equals(BYTES), 'and they are byte-identical') |
| 242 | |
| 243 | const again = await b.reconcile() |
| 244 | t.is(again.missing, 0, 'idempotent — a converged node has nothing to pull') |
| 245 | t.is(again.fetched, 0, 'and does no work') |
| 246 | |
| 247 | await a.close(); await b.close() |
| 248 | }) |
| 249 | |
| 250 | test('reconcile is BOUNDED per pass and reports `more`', async (t) => { |
| 251 | const net = new Map(); const shared = new Map() |
| 252 | const a = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net)) |
| 253 | for (let i = 0; i < 5; i++) await a.put(Buffer.from('blob number ' + i), 'image/png') |
| 254 | |
| 255 | const { blobs: bBlobs } = await mkNodeAt(t, net) |
| 256 | const b = new BlobPointerStore(mkStatebase(shared), bBlobs) |
| 257 | |
| 258 | const first = await b.reconcile({ limit: 2 }) |
| 259 | t.is(first.fetched, 2, 'a bounded pass pulls at most `limit`') |
| 260 | t.ok(first.more, 'and reports there is more to do') |
| 261 | |
| 262 | let guard = 0 |
| 263 | while ((await b.reconcile({ limit: 2 })).more && guard++ < 10) { /* drain */ } |
| 264 | t.is((await b.reconcile({ limit: 2 })).missing, 0, 'successive passes converge the backlog') |
| 265 | |
| 266 | await a.close(); await b.close() |
| 267 | }) |
| 268 | |
| 269 | test('reconcile is PULL-ONLY — it never writes a pointer (safe on a non-writable replica)', async (t) => { |
| 270 | const net = new Map(); const shared = new Map() |
| 271 | const a = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net)) |
| 272 | await a.put(BYTES, MIME) |
| 273 | const before = [...shared.keys()].sort() |
| 274 | |
| 275 | // A statebase whose set() THROWS — exactly a non-writable co-owner. reconcile must still converge. |
| 276 | const readOnly = { |
| 277 | async set () { throw new Error('not writable') }, |
| 278 | async get (k) { return shared.has(k) ? shared.get(k) : null }, |
| 279 | async list (prefix = '') { const o = {}; for (const [k, v] of shared) if (k.startsWith(prefix)) o[k] = v; return o } |
| 280 | } |
| 281 | const { blobs: bBlobs } = await mkNodeAt(t, net) |
| 282 | const b = new BlobPointerStore(readOnly, bBlobs) |
| 283 | |
| 284 | const r = await b.reconcile() |
| 285 | t.is(r.fetched, 1, 'a NON-WRITABLE replica still pulls the bytes') |
| 286 | t.alike([...shared.keys()].sort(), before, 'and wrote nothing to the shared state') |
| 287 | |
| 288 | await a.close(); await b.close() |
| 289 | }) |
| 290 | |
| 291 | test('an unresolvable pointer is counted, not thrown, and does not block the rest', async (t) => { |
| 292 | const net = new Map(); const shared = new Map() |
| 293 | const a = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net)) |
| 294 | const { cid } = await a.put(BYTES, MIME) |
| 295 | shared.set(BLOB_PREFIX + 'bafkreighost0000000000000000000000000000000000000000', { $oid: 'nobody-has-this', size: 5, mimeType: 'image/png' }) |
| 296 | |
| 297 | const { blobs: bBlobs } = await mkNodeAt(t, net) |
| 298 | const b = new BlobPointerStore(mkStatebase(shared), bBlobs) |
| 299 | const r = await b.reconcile() |
| 300 | |
| 301 | t.is(r.fetched, 1, 'the resolvable blob was pulled') |
| 302 | t.is(r.failed, 1, 'the unresolvable one was counted') |
| 303 | t.ok(bBlobs.hasLocalFile(cid.toString()), 'and the good blob is present') |
| 304 | |
| 305 | await a.close(); await b.close() |
| 306 | }) |