|
9 | 9 | * (filesystem GC is tracked in issue #708 — RocksDB audit store does not |
10 | 10 | * invoke blob-file delete callbacks, so filesystem cleanup is not asserted) |
11 | 11 | * - 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 |
12 | 14 | * |
13 | 15 | * Self-contained: installs the `blobs` component, sets auditLog + |
14 | 16 | * auditRetention: 10s, restarts HTTP workers, and tears everything down. |
|
21 | 23 | import { suite, test, before, after } from 'node:test'; |
22 | 24 | import assert from 'node:assert/strict'; |
23 | 25 | import path from 'node:path'; |
| 26 | +import os from 'node:os'; |
24 | 27 | import fs from 'fs-extra'; |
25 | 28 | import { randomInt } from 'node:crypto'; |
26 | 29 | import { setTimeout } from 'node:timers/promises'; |
@@ -262,3 +265,315 @@ suite('Blob lifecycle', { skip: skipSuite }, (ctx) => { |
262 | 265 | } |
263 | 266 | }); |
264 | 267 | }); |
| 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