Skip to content

Commit eec7c04

Browse files
test(blob): add multi-path blobPaths striping and per-device-type LMDB sharding suites
Expands integrationTests/apiTests/blob.test.mjs with two new suites: 1. Blob multi-path blobPaths striping — configures storage.blobPaths with two paths, creates 8 file-backed blobs, and asserts that the round-robin distribution places files in both directories while all blobs remain retrievable (Closes #1195 – blobPaths case). 2. Per-device-type LMDB database sharding — starts Harper with HARPER_STORAGE_ENGINE=lmdb and installs a component with three tables (ThermostatBlob, DoorlockBlob, SensorBlob) in three separate LMDB databases sharing the same schema shape. Asserts that all three schemas are visible, each blob is created and retrievable via REST, SQL queries target each database independently, and each device type has its own blob storage sub-directory (Closes #1195 – LMDB sharding case). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 88c94e6 commit eec7c04

1 file changed

Lines changed: 315 additions & 0 deletions

File tree

integrationTests/apiTests/blob.test.mjs

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
* (filesystem GC is tracked in issue #708 — RocksDB audit store does not
1010
* invoke blob-file delete callbacks, so filesystem cleanup is not asserted)
1111
* - Schema drop also cleans up blob files
12+
* - Multi-path blobPaths striping: blobs distributed and retrievable from 2+ configured paths
13+
* - Per-device-type LMDB sharding: three tables in three separate LMDB databases, same schema
1214
*
1315
* Self-contained: installs the `blobs` component, sets auditLog +
1416
* auditRetention: 10s, restarts HTTP workers, and tears everything down.
@@ -21,6 +23,7 @@
2123
import { suite, test, before, after } from 'node:test';
2224
import assert from 'node:assert/strict';
2325
import path from 'node:path';
26+
import os from 'node:os';
2427
import fs from 'fs-extra';
2528
import { randomInt } from 'node:crypto';
2629
import { setTimeout } from 'node:timers/promises';
@@ -262,3 +265,315 @@ suite('Blob lifecycle', { skip: skipSuite }, (ctx) => {
262265
}
263266
});
264267
});
268+
269+
// ─── Multi-path blobPaths striping ───────────────────────────────────────────
270+
//
271+
// Verifies that configuring `storage.blobPaths` with two paths causes Harper
272+
// to distribute file-backed blobs across both paths (round-robin by file-id),
273+
// and that every stored blob remains readable regardless of which path holds it.
274+
275+
const BLOB_STRIPE_COUNT = 8; // enough for round-robin to populate both paths
276+
277+
suite('Blob multi-path blobPaths striping', { skip: skipSuite }, (ctx) => {
278+
let client;
279+
let blobPath1;
280+
let blobPath2;
281+
const blobIds = Array.from({ length: BLOB_STRIPE_COUNT }, () => randomInt(1000000));
282+
283+
before(async () => {
284+
// Pre-seed ctx.harper so blobPaths can live inside the Harper data root
285+
// and are cleaned up automatically by teardownHarper.
286+
const dataRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'harper-blobs-stripe-'));
287+
blobPath1 = path.join(dataRootDir, 'stripe-0');
288+
blobPath2 = path.join(dataRootDir, 'stripe-1');
289+
ctx.harper = { dataRootDir };
290+
291+
await startHarper(ctx, {
292+
config: {
293+
logging: { auditLog: false },
294+
storage: { blobPaths: [blobPath1, blobPath2] },
295+
},
296+
env: {},
297+
});
298+
client = createApiClient(ctx.harper);
299+
300+
await client
301+
.req()
302+
.send({ operation: 'add_component', project: 'blobs' })
303+
.expect((r) => {
304+
const res = JSON.stringify(r.body);
305+
assert.ok(res.includes('Successfully added project') || res.includes('Project already exists'), r.text);
306+
});
307+
308+
await client
309+
.req()
310+
.send({ operation: 'set_component_file', project: 'blobs', file: 'schema.graphql', payload: SCHEMA_GRAPHQL })
311+
.expect((r) => assert.ok(r.body.message.includes('Successfully set component: schema.graphql'), r.text))
312+
.expect(200);
313+
314+
await client
315+
.req()
316+
.send({ operation: 'set_component_file', project: 'blobs', file: 'resources.js', payload: RESOURCES_JS })
317+
.expect((r) => assert.ok(r.body.message.includes('Successfully set component: resources.js'), r.text))
318+
.expect(200);
319+
320+
await restartHttpWorkers(client, '/openapi', 120000);
321+
});
322+
323+
after(async () => {
324+
// teardownHarper removes ctx.harper.dataRootDir, which contains both stripe dirs.
325+
await teardownHarper(ctx);
326+
});
327+
328+
test('BlobCache schema created after component load', async () => {
329+
await client
330+
.req()
331+
.send({ operation: 'describe_all' })
332+
.expect((r) => {
333+
assert.ok(JSON.stringify(r.body).includes('"blob":{"BlobCache":{"schema":"blob","name":"BlobCache"'), r.text);
334+
})
335+
.expect(200);
336+
});
337+
338+
test(`create ${BLOB_STRIPE_COUNT} blobs via sourced REST resource`, async () => {
339+
for (const id of blobIds) {
340+
await client
341+
.reqRest(`/blobcache/${id}`)
342+
.set('Accept', '*/*')
343+
.expect((r) => {
344+
assert.ok(
345+
parseInt(r.headers['content-length']) >= 80000 && parseInt(r.headers['content-length']) <= 120000,
346+
`blob ${id}: content-length out of expected range\n` + r.text
347+
);
348+
})
349+
.expect(200);
350+
}
351+
});
352+
353+
test('blobs distributed across both configured storage paths', async () => {
354+
await setTimeout(5000); // Allow blob GC flush to disk
355+
356+
if (process.env.DOCKER_CONTAINER_ID) return;
357+
358+
// Harper stores blobs at {blobPath}/{databaseName}/...
359+
const dbName = 'blob'; // from @table(database: "blob")
360+
const dir1 = path.join(blobPath1, dbName);
361+
const dir2 = path.join(blobPath2, dbName);
362+
363+
const files1 = (await fs.pathExists(dir1))
364+
? (await fs.readdir(dir1, { recursive: true })).filter((f) => !f.startsWith('.'))
365+
: [];
366+
const files2 = (await fs.pathExists(dir2))
367+
? (await fs.readdir(dir2, { recursive: true })).filter((f) => !f.startsWith('.'))
368+
: [];
369+
370+
assert.ok(
371+
files1.length + files2.length >= BLOB_STRIPE_COUNT,
372+
`Expected at least ${BLOB_STRIPE_COUNT} blob files across both paths, found ${files1.length} + ${files2.length}`
373+
);
374+
assert.ok(files1.length > 0, `blobPath1 (${dir1}) received no files — round-robin striping did not use this path`);
375+
assert.ok(files2.length > 0, `blobPath2 (${dir2}) received no files — round-robin striping did not use this path`);
376+
});
377+
378+
test('all blobs retrievable regardless of which path holds their file', async () => {
379+
for (const id of blobIds) {
380+
await client
381+
.reqRest(`/blobcache/${id}`)
382+
.set('Accept', '*/*')
383+
.expect((r) => {
384+
assert.ok(
385+
parseInt(r.headers['content-length']) >= 80000 && parseInt(r.headers['content-length']) <= 120000,
386+
`blob ${id} not retrievable after striping\n` + r.text
387+
);
388+
})
389+
.expect(200);
390+
}
391+
});
392+
});
393+
394+
// ─── Per-device-type LMDB database sharding ──────────────────────────────────
395+
//
396+
// Validates the pattern where device-type-specific data lives in separate LMDB
397+
// databases (one per type) that all share the same table schema shape.
398+
// Each database gets its own blob storage sub-directory under the Harper root,
399+
// confirming true storage isolation between device types.
400+
401+
const DEVICE_SCHEMA_GRAPHQL =
402+
'type ThermostatBlob @table(database: "thermostat") @sealed @export {\n' +
403+
'\tdeviceId: ID! @primaryKey\n' +
404+
'\tpayload: Blob!\n' +
405+
'\tfirmware: String\n' +
406+
'}\n\n' +
407+
'type DoorlockBlob @table(database: "doorlock") @sealed @export {\n' +
408+
'\tdeviceId: ID! @primaryKey\n' +
409+
'\tpayload: Blob!\n' +
410+
'\tfirmware: String\n' +
411+
'}\n\n' +
412+
'type SensorBlob @table(database: "sensor") @sealed @export {\n' +
413+
'\tdeviceId: ID! @primaryKey\n' +
414+
'\tpayload: Blob!\n' +
415+
'\tfirmware: String\n' +
416+
'}\n\n';
417+
418+
const DEVICE_RESOURCES_JS =
419+
"import { randomBytes } from 'crypto';\n" +
420+
'\n' +
421+
'const { ThermostatBlob } = databases.thermostat;\n' +
422+
'const { DoorlockBlob } = databases.doorlock;\n' +
423+
'const { SensorBlob } = databases.sensor;\n' +
424+
'\n' +
425+
'const devicePayload = randomBytes(20000);\n' +
426+
'\n' +
427+
'export class ThermostatBlobSource extends Resource {\n' +
428+
'\tasync get() {\n' +
429+
'\t\treturn { payload: createBlob(devicePayload), firmware: "1.0" };\n' +
430+
'\t}\n' +
431+
'}\n' +
432+
'export class DoorlockBlobSource extends Resource {\n' +
433+
'\tasync get() {\n' +
434+
'\t\treturn { payload: createBlob(devicePayload), firmware: "1.0" };\n' +
435+
'\t}\n' +
436+
'}\n' +
437+
'export class SensorBlobSource extends Resource {\n' +
438+
'\tasync get() {\n' +
439+
'\t\treturn { payload: createBlob(devicePayload), firmware: "1.0" };\n' +
440+
'\t}\n' +
441+
'}\n' +
442+
'\n' +
443+
'export class thermostatblob extends ThermostatBlob {\n' +
444+
'\tasync get() { return { status: 200, headers: {}, body: this.payload }; }\n' +
445+
'}\n' +
446+
'export class doorlockblob extends DoorlockBlob {\n' +
447+
'\tasync get() { return { status: 200, headers: {}, body: this.payload }; }\n' +
448+
'}\n' +
449+
'export class sensorblob extends SensorBlob {\n' +
450+
'\tasync get() { return { status: 200, headers: {}, body: this.payload }; }\n' +
451+
'}\n' +
452+
'\n' +
453+
'thermostatblob.sourcedFrom(ThermostatBlobSource);\n' +
454+
'doorlockblob.sourcedFrom(DoorlockBlobSource);\n' +
455+
'sensorblob.sourcedFrom(SensorBlobSource);\n\n';
456+
457+
suite('Per-device-type LMDB database sharding', { skip: skipSuite }, (ctx) => {
458+
let client;
459+
const thermostatId = randomInt(1000000);
460+
const doorlockId = randomInt(1000000);
461+
const sensorId = randomInt(1000000);
462+
let rootPath;
463+
464+
before(async () => {
465+
await startHarper(ctx, {
466+
config: { logging: { auditLog: false } },
467+
env: { HARPER_STORAGE_ENGINE: 'lmdb' },
468+
});
469+
client = createApiClient(ctx.harper);
470+
471+
await client
472+
.req()
473+
.send({ operation: 'add_component', project: 'devicesharding' })
474+
.expect((r) => {
475+
const res = JSON.stringify(r.body);
476+
assert.ok(res.includes('Successfully added project') || res.includes('Project already exists'), r.text);
477+
});
478+
479+
await client
480+
.req()
481+
.send({
482+
operation: 'set_component_file',
483+
project: 'devicesharding',
484+
file: 'schema.graphql',
485+
payload: DEVICE_SCHEMA_GRAPHQL,
486+
})
487+
.expect((r) => assert.ok(r.body.message.includes('Successfully set component: schema.graphql'), r.text))
488+
.expect(200);
489+
490+
await client
491+
.req()
492+
.send({
493+
operation: 'set_component_file',
494+
project: 'devicesharding',
495+
file: 'resources.js',
496+
payload: DEVICE_RESOURCES_JS,
497+
})
498+
.expect((r) => assert.ok(r.body.message.includes('Successfully set component: resources.js'), r.text))
499+
.expect(200);
500+
501+
await restartHttpWorkers(client, '/openapi', 120000);
502+
503+
const configResp = await client.req().send({ operation: 'get_configuration' }).expect(200);
504+
rootPath = configResp.body.rootPath;
505+
});
506+
507+
after(async () => {
508+
await teardownHarper(ctx);
509+
});
510+
511+
test('all three device-type schemas are visible in describe_all', async () => {
512+
const r = await client.req().send({ operation: 'describe_all' }).expect(200);
513+
const body = JSON.stringify(r.body);
514+
assert.ok(body.includes('"thermostat":{"ThermostatBlob"'), `thermostat schema missing\n` + r.text);
515+
assert.ok(body.includes('"doorlock":{"DoorlockBlob"'), `doorlock schema missing\n` + r.text);
516+
assert.ok(body.includes('"sensor":{"SensorBlob"'), `sensor schema missing\n` + r.text);
517+
});
518+
519+
test('create a blob for each device type via sourced REST resource', async () => {
520+
for (const [endpoint, id] of [
521+
['thermostatblob', thermostatId],
522+
['doorlockblob', doorlockId],
523+
['sensorblob', sensorId],
524+
]) {
525+
await client
526+
.reqRest(`/${endpoint}/${id}`)
527+
.set('Accept', '*/*')
528+
.expect((r) => {
529+
assert.ok(parseInt(r.headers['content-length']) === 20000, `${endpoint} blob size unexpected\n` + r.text);
530+
})
531+
.expect(200);
532+
}
533+
});
534+
535+
test('each device-type blob is retrievable from its own LMDB database', async () => {
536+
for (const [endpoint, id] of [
537+
['thermostatblob', thermostatId],
538+
['doorlockblob', doorlockId],
539+
['sensorblob', sensorId],
540+
]) {
541+
await client
542+
.reqRest(`/${endpoint}/${id}`)
543+
.set('Accept', '*/*')
544+
.expect((r) => {
545+
assert.ok(parseInt(r.headers['content-length']) === 20000, `${endpoint}/${id} not retrievable\n` + r.text);
546+
})
547+
.expect(200);
548+
}
549+
});
550+
551+
test('SQL queries target each device database independently', async () => {
552+
for (const [db, table] of [
553+
['thermostat', 'ThermostatBlob'],
554+
['doorlock', 'DoorlockBlob'],
555+
['sensor', 'SensorBlob'],
556+
]) {
557+
const r = await client
558+
.req()
559+
.send({ operation: 'sql', sql: `SELECT deviceId, firmware FROM ${db}.${table}` })
560+
.expect(200);
561+
assert.ok(Array.isArray(r.body) && r.body.length === 1, `${db}.${table}: expected 1 record\n` + r.text);
562+
assert.equal(r.body[0].firmware, '1.0', `${db}.${table}: unexpected firmware value\n` + r.text);
563+
}
564+
});
565+
566+
test('each device type has a separate blob storage directory', async () => {
567+
await setTimeout(5000); // Allow blob flush to disk
568+
569+
if (process.env.DOCKER_CONTAINER_ID) return;
570+
assert.ok(rootPath, 'rootPath not obtained from get_configuration');
571+
572+
for (const dbName of ['thermostat', 'doorlock', 'sensor']) {
573+
const blobDir = path.join(rootPath, 'blobs', dbName);
574+
assert.ok(await fs.pathExists(blobDir), `expected blob directory for ${dbName} at ${blobDir}`);
575+
const files = (await fs.readdir(blobDir, { recursive: true })).filter((f) => !f.startsWith('.'));
576+
assert.ok(files.length > 0, `expected blob files in ${blobDir} for device type ${dbName}`);
577+
}
578+
});
579+
});

0 commit comments

Comments
 (0)