From f581d9fe07db94e7530fb860675c770d88830457 Mon Sep 17 00:00:00 2001 From: Oli Evans Date: Wed, 5 Oct 2022 22:13:54 +0100 Subject: [PATCH] feat: add treeleaf strategy Adds `treeleaf` strategy to put all non-leaf nodes in the first CAR, and then all leaf nodes in subsequent CARs. Note: no attempt is made to restrict the tree CAR size... if you have a huge tree and a smol target size, it may well exceeed it... this PR is a sketch of the idea of the "put all the tree meta data in one CAR, and the actual file data (leaf nodes) in other cars. License: MIT Signed-off-by: Oli Evans --- README.md | 53 ++++++++++++- lib/treeleaf/index.js | 7 ++ lib/treeleaf/joiner.js | 41 ++++++++++ lib/treeleaf/splitter.js | 112 +++++++++++++++++++++++++++ package-lock.json | 44 ++++++++--- package.json | 1 + test/treeleaf-splitter.spec.js | 135 +++++++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 lib/treeleaf/index.js create mode 100644 lib/treeleaf/joiner.js create mode 100644 lib/treeleaf/splitter.js create mode 100644 test/treeleaf-splitter.spec.js diff --git a/README.md b/README.md index e323862..79a742f 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ npm install carbites ## Usage -Carbites supports 3 different strategies: +Carbites supports 4 different strategies: 1. [**Simple**](#simple) (default) - fast but naive, only the first CAR output has a root CID, subsequent CARs have a placeholder "empty" CID. 2. [**Rooted**](#rooted) - like simple, but creates a custom root node to ensure all blocks in a CAR are referenced. 3. [**Treewalk**](#treewalk) - walks the DAG to pack sub-graphs into each CAR file that is output. Every CAR has the same root CID, but contains a different portion of the DAG. +4. [**Treeleaf**](#treeleaf) - the first CAR contains all non-leaf nodes. Subsequent CARs contain only leaf-nodes. ### Simple @@ -116,7 +117,55 @@ Every CAR file has the _same_ root CID but a different portion of the DAG. The D -### CLI +### Treeleaf + +
+ Example + + ```js + import { TreeleafCarSplitter } from 'carbites/treeleaf' + import { CarReader } from '@ipld/car/reader' + import { printTree } from 'imaginary-car-tree-lib' + import fs from 'fs' + + const bigCar = await CarReader.fromIterable(fs.createReadStream('/path/to/big.car')) + const targetSize = 1024 * 1024 * 100 // chunk to ~100MB CARs + const splitter = new TreeleafCarSplitter(bigCar, targetSize) + + const count = 0 + for await (const car of splitter.cars()) { + const reader = await CarReader.fromIterable(car) + const [splitCarRootCid] = await reader.getRoots() + console.log(`## car ${count} – root: ${splitCarRootCid}`) + console.log(printTree(reader)) + count++ + } + /* output: + ## car 0 - root: CID(bafybeidpq6auelplqkozwd3kobvgv4jow72r2yt6ffjzenemqhylmmxngi) + bafybeidpq6auelplqkozwd3kobvgv4jow72r2yt6ffjzenemqhylmmxngi + └─┬ bafybeibw77p4afchrvmo6ep4fpv7j4aehrn2yqs3tv3uuofc6oag36zq2q + ├─┬ bafybeih2w5euyf3sodc6efxtpahgwkata46fupjysi4bbnazb4n2b25rry + │ └─┬ bafybeif5xvhik6thha5ykg3g73jo3mqd5mhb7orzeznnvwquashmwryhai + │ ├── bafkreidkmypp3asq3xiu6mmtcfbzmmhcobpmnaqcyns6535w3u7bz7el64 ❌ missing + │ ├── bafkreid3pefuwyvhsnlrz6xk67f4f6opgqhcrle4bf47kvachwojdgt7re ❌ missing + │ └── bafkreigr3beehu5ebbgoisx4rl2vyaqebmohubauc6avefodb5jauz3tyi ❌ missing + └─┬ bafybeia2i6eqwfqrixh446pavwev37kywowmdpgvx34fbv7asdh3qwpm3y + └── bafkreih2bhak5yv7g4vft5c37j7dw5rqnnsyyuzsifczehhhpm3t655oae ❌ missing + + ## car 1 - root: CID(bafkqaaa) + bafkreidkmypp3asq3xiu6mmtcfbzmmhcobpmnaqcyns6535w3u7bz7el64 + bafkreid3pefuwyvhsnlrz6xk67f4f6opgqhcrle4bf47kvachwojdgt7re + bafkreigr3beehu5ebbgoisx4rl2vyaqebmohubauc6avefodb5jauz3tyi + bafkreih2bhak5yv7g4vft5c37j7dw5rqnnsyyuzsifczehhhpm3t655oae + */ + ``` + + ⚠️ Note: The first CAR output has roots in the header, subsequent CARs have an empty root CID [`bafkqaaa`](https://cid.ipfs.io/#bafkqaaa) as [recommended](https://ipld.io/specs/transport/car/carv1/#number-of-roots). + +
+ + +## CLI Install the CLI tool to use Carbites from the comfort of your terminal: diff --git a/lib/treeleaf/index.js b/lib/treeleaf/index.js new file mode 100644 index 0000000..45cd3a6 --- /dev/null +++ b/lib/treeleaf/index.js @@ -0,0 +1,7 @@ +import { TreeleafCarSplitter } from './splitter.js' +import { TreeleafCarJoiner } from './joiner.js' + +export { + TreeleafCarSplitter, + TreeleafCarJoiner +} diff --git a/lib/treeleaf/joiner.js b/lib/treeleaf/joiner.js new file mode 100644 index 0000000..9da4aa8 --- /dev/null +++ b/lib/treeleaf/joiner.js @@ -0,0 +1,41 @@ +import { CarWriter } from '@ipld/car' + +/** + * @typedef {import('@ipld/car/api').BlockReader & import('@ipld/car/api').RootsReader} ICarReader + */ + +export class TreeleafCarJoiner { + /** + * @param {Iterable} cars + */ + constructor (cars) { + /** @type {ICarReader[]} */ + this._cars = Array.from(cars) + if (!this._cars.length) throw new Error('missing CARs') + } + + async * car () { + const reader = this._cars[0] + const roots = await reader.getRoots() + const { writer, out } = CarWriter.create(roots) + const writeCar = async () => { + try { + for await (const b of reader.blocks()) { + await writer.put(b) + } + for (const reader of this._cars.slice(1)) { + for await (const b of reader.blocks()) { + await writer.put(b) + } + } + } catch (err) { + console.error(err) // TODO: how to forward this on? + } finally { + await writer.close() + } + } + + writeCar() + yield * out + } +} diff --git a/lib/treeleaf/splitter.js b/lib/treeleaf/splitter.js new file mode 100644 index 0000000..ec2bc47 --- /dev/null +++ b/lib/treeleaf/splitter.js @@ -0,0 +1,112 @@ +import { CarReader, CarWriter } from '@ipld/car' +import * as Block from 'multiformats/block' +import { CID } from 'multiformats/cid' +import * as raw from 'multiformats/codecs/raw' +import * as cbor from '@ipld/dag-cbor' +import * as json from '@ipld/dag-json' +import * as pb from '@ipld/dag-pb' + +/** + * @typedef {import('@ipld/car/api').BlockReader & import('@ipld/car/api').RootsReader} ICarReader + * @typedef {import('@ipld/car/api').WriterChannel} WriterChannel + * @typedef {import('multiformats/codecs/interface').BlockDecoder} BlockDecoder + * @typedef {{ decoders?: BlockDecoder[] }} Options + * @typedef {number} Code + */ + +/** + * A work-around for use-cases where the inclusion of a root CID is difficult + * but needing to be safely within the "at least one" recommendation is to use + * an empty CID: \x01\x55\x00\x00 (zero-length "identity" multihash with "raw" + * codec). Since current implementations for this version of the CAR + * specification don't check for the existence of root CIDs (see Root CID block + * existence), this will be safe as far as CAR implementations are concerned. + * However, there is no guarantee that applications that use CAR files will + * correctly consume (ignore) this empty root CID. + * + * https://github.com/ipld/specs/blob/master/block-layer/content-addressable-archives.md#number-of-roots + */ +const empty = CID.parse('bafkqaaa') + +export class TreeleafCarSplitter { + /** + * @param {ICarReader} reader + * @param {number} targetSize + * @param {Options} [options] + */ + constructor (reader, targetSize, options = {}) { + if (typeof targetSize !== 'number' || targetSize <= 0) { + throw new Error('invalid target chunk size') + } + this._reader = reader + this._targetSize = targetSize + const codecs = [pb, raw, cbor, json, ...(options.decoders || [])] + /** @type Map */ + this._decoderMap = new Map(codecs.map(d => [d.code, d])) + } + + async * cars () { + /** @type {(roots?: CID[]) => WriterChannel} */ + const createCarWriter = (roots = [empty]) => { + const car = CarWriter.create(roots) + Object.assign(car.out, { version: 1, getRoots: async () => roots }) + return car + } + const leaves = [] + const originRoots = await this._reader.getRoots() + const tree = createCarWriter(originRoots) + + for await (const block of this._reader.blocks()) { + if (block.cid.code === raw.code) { + leaves.push(block) + continue + } + const codec = this._decoderMap.get(block.cid.code) + if (!codec) throw new Error(`missing decoder for ${block.cid.code}`) + const decoded = Block.createUnsafe({ cid: block.cid, bytes: block.bytes, codec }) + const hasLinks = Array.from(decoded.links()).length > 0 + if (hasLinks) { + tree.writer.put(block) + } else { + leaves.push(block) + } + } + tree.writer.close() + yield tree.out + + // finished tree car, now write multiple leaf cars + + let size = 0 + let car = createCarWriter() + for (const block of leaves) { + if (size >= this._targetSize) { + car.writer.close() + yield car.out + car = createCarWriter() + } + car.writer.put(block) + size += block.bytes.length + } + car.writer.close() + yield car.out + } + + /** + * @param {Blob} blob + * @param {number} targetSize + */ + static async fromBlob (blob, targetSize) { + const buffer = await blob.arrayBuffer() + const reader = await CarReader.fromBytes(new Uint8Array(buffer)) + return new TreeleafCarSplitter(reader, targetSize) + } + + /** + * @param {AsyncIterable} iterable + * @param {number} targetSize + */ + static async fromIterable (iterable, targetSize) { + const reader = await CarReader.fromIterable(iterable) + return new TreeleafCarSplitter(reader, targetSize) + } +} diff --git a/package-lock.json b/package-lock.json index 89f7649..aa459e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,13 @@ "requires": true, "packages": { "": { + "name": "carbites", "version": "1.0.6", "license": "(Apache-2.0 AND MIT)", "dependencies": { "@ipld/car": "^3.0.1", "@ipld/dag-cbor": "^6.0.3", + "@ipld/dag-json": "^8.0.11", "@ipld/dag-pb": "^2.0.2", "multiformats": "^9.0.4" }, @@ -135,6 +137,15 @@ "multiformats": "^9.0.0" } }, + "node_modules/@ipld/dag-json": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/@ipld/dag-json/-/dag-json-8.0.11.tgz", + "integrity": "sha512-Pea7JXeYHTWXRTIhBqBlhw7G53PJ7yta3G/sizGEZyzdeEwhZRr0od5IQ0r2ZxOt1Do+2czddjeEPp+YTxDwCA==", + "dependencies": { + "cborg": "^1.5.4", + "multiformats": "^9.5.4" + } + }, "node_modules/@ipld/dag-pb": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@ipld/dag-pb/-/dag-pb-2.0.2.tgz", @@ -1010,9 +1021,9 @@ } }, "node_modules/cborg": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-1.3.4.tgz", - "integrity": "sha512-w/sCTLy2k4AI6XbDMiaPsGc07bBAcX/It3VYQBwKJ5fvAAyUOXi5nC1wjW0OXIfY5ROkOGJCAUOR5AW1ykkiCQ==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-1.9.5.tgz", + "integrity": "sha512-fLBv8wmqtlXqy1Yu+pHzevAIkW6k2K0ZtMujNzWphLsA34vzzg9BHn+5GmZqOJkSA9V7EMKsWrf6K976c1QMjQ==", "bin": { "cborg": "cli.js" } @@ -3738,9 +3749,9 @@ "dev": true }, "node_modules/multiformats": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.0.4.tgz", - "integrity": "sha512-QWGCSOO4aSEZO4fUCZC1tkvd6rtPTzkAT5AaEshWWJIwd544YTBrrJFmICTp/hEUiOhOyDBJuwnjsFQCTIKLeQ==" + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5778,6 +5789,15 @@ "multiformats": "^9.0.0" } }, + "@ipld/dag-json": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/@ipld/dag-json/-/dag-json-8.0.11.tgz", + "integrity": "sha512-Pea7JXeYHTWXRTIhBqBlhw7G53PJ7yta3G/sizGEZyzdeEwhZRr0od5IQ0r2ZxOt1Do+2czddjeEPp+YTxDwCA==", + "requires": { + "cborg": "^1.5.4", + "multiformats": "^9.5.4" + } + }, "@ipld/dag-pb": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@ipld/dag-pb/-/dag-pb-2.0.2.tgz", @@ -6491,9 +6511,9 @@ "dev": true }, "cborg": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-1.3.4.tgz", - "integrity": "sha512-w/sCTLy2k4AI6XbDMiaPsGc07bBAcX/It3VYQBwKJ5fvAAyUOXi5nC1wjW0OXIfY5ROkOGJCAUOR5AW1ykkiCQ==" + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-1.9.5.tgz", + "integrity": "sha512-fLBv8wmqtlXqy1Yu+pHzevAIkW6k2K0ZtMujNzWphLsA34vzzg9BHn+5GmZqOJkSA9V7EMKsWrf6K976c1QMjQ==" }, "chalk": { "version": "2.4.2", @@ -8676,9 +8696,9 @@ "dev": true }, "multiformats": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.0.4.tgz", - "integrity": "sha512-QWGCSOO4aSEZO4fUCZC1tkvd6rtPTzkAT5AaEshWWJIwd544YTBrrJFmICTp/hEUiOhOyDBJuwnjsFQCTIKLeQ==" + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" }, "natural-compare": { "version": "1.4.0", diff --git a/package.json b/package.json index 1f8a02b..c637960 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@ipld/car": "^3.0.1", "@ipld/dag-cbor": "^6.0.3", + "@ipld/dag-json": "^8.0.11", "@ipld/dag-pb": "^2.0.2", "multiformats": "^9.0.4" }, diff --git a/test/treeleaf-splitter.spec.js b/test/treeleaf-splitter.spec.js new file mode 100644 index 0000000..cf1270d --- /dev/null +++ b/test/treeleaf-splitter.spec.js @@ -0,0 +1,135 @@ +import test from 'ava' +import { CarReader } from '@ipld/car' +import * as Block from 'multiformats/block' +import { CID } from 'multiformats/cid' +import * as raw from 'multiformats/codecs/raw' +import * as cbor from '@ipld/dag-cbor' +import * as json from '@ipld/dag-json' +import * as pb from '@ipld/dag-pb' +import { Blob } from '@web-std/blob' +import { collect, collectBytes, randomCar, toAsyncIterable } from './_helpers.js' +import { TreeleafCarSplitter } from '../lib/treeleaf/splitter.js' + +const decoders = new Map([raw, cbor, json, pb].map(d => [d.code, d])) + +const empty = CID.parse('bafkqaaa') + +test('split in ~two', async t => { + const targetSize = 500 + const bytes = await collectBytes(await randomCar(1000)) + const blocks = await collect((await CarReader.fromBytes(bytes)).blocks()) + const splitter = new TreeleafCarSplitter(await CarReader.fromBytes(bytes), targetSize) + const cars = [] + for await (const car of splitter.cars()) { + cars.push(car) + } + t.true(cars.length >= 2) + const chunkedBlocks = [] + for (const c of cars) { + const bs = await collect((await CarReader.fromIterable(c)).blocks()) + chunkedBlocks.push(...bs) + } + t.is(blocks.length, chunkedBlocks.length) +}) + +test('target size bigger than file size', async t => { + const bytes = await collectBytes(await randomCar(1000)) + const splitter = new TreeleafCarSplitter(await CarReader.fromBytes(bytes), Infinity) + const cars = [] + for await (const car of splitter.cars()) { + cars.push(car) + } + t.is(cars.length, 2, 'treeleaf always splits tree from leaf nodes') + + const treeCar = await CarReader.fromIterable(cars[0]) + for await (const { cid, bytes } of treeCar.blocks()) { + const codec = decoders.get(cid.code) + const decoded = Block.createUnsafe({ cid, bytes, codec }) + t.true(Array.from(decoded.links()).length > 0, 'All blocks in tree car should have links') + } + + const leafCar = await CarReader.fromIterable(cars[1]) + for await (const { cid, bytes } of leafCar.blocks()) { + const codec = decoders.get(cid.code) + const decoded = Block.createUnsafe({ cid, bytes, codec }) + t.is(Array.from(decoded.links()).length, 0, 'All blocks in leaf car should have no links') + } +}) + +test('roots in first CAR, empty CID root in other CARs', async t => { + const targetSize = 100 + const bytes = await collectBytes(await randomCar(1000)) + const splitter = new TreeleafCarSplitter(await CarReader.fromBytes(bytes), targetSize) + let i = 0 + for await (const car of splitter.cars()) { + const reader = await CarReader.fromIterable(car) + const roots = await reader.getRoots() + if (i === 0) { + t.true(roots.length > 0) + } else { + t.is(roots.length, 1) + t.true(empty.equals(roots[0])) + } + i++ + } +}) + +test('bad target size', t => { + t.throws(() => new TreeleafCarSplitter(null, -1)) +}) + +test('split CARs are RootsReader', async t => { + const targetSize = 500 + const bytes = await collectBytes(await randomCar(1000)) + const reader = await CarReader.fromBytes(bytes) + const splitter = new TreeleafCarSplitter(reader, targetSize) + const splitRoots = [] + for await (const car of splitter.cars()) { + const roots = await car.getRoots() + splitRoots.push(...roots) + } + const originRoots = await reader.getRoots() + for (const [i, r] of originRoots.entries()) { + t.true(r.equals(splitRoots[i])) + } + // non-origin roots should be empty CID + splitRoots.slice(originRoots.length).forEach(r => { + t.true(r.equals(empty)) + }) +}) + +test('fromBlob', async t => { + const targetSize = 500 + const bytes = await collectBytes(await randomCar(1000)) + const blocks = await collect((await CarReader.fromBytes(bytes)).blocks()) + const splitter = await TreeleafCarSplitter.fromBlob(new Blob([bytes]), targetSize) + const cars = [] + for await (const car of splitter.cars()) { + cars.push(car) + } + t.true(cars.length >= 2) + const chunkedBlocks = [] + for (const c of cars) { + const bs = await collect((await CarReader.fromIterable(c)).blocks()) + chunkedBlocks.push(...bs) + } + t.is(blocks.length, chunkedBlocks.length) +}) + +test('fromIterable', async t => { + const targetSize = 500 + const bytes = await collectBytes(await randomCar(1000)) + const blocks = await collect((await CarReader.fromBytes(bytes)).blocks()) + const splitter = await TreeleafCarSplitter.fromIterable(toAsyncIterable([bytes]), targetSize) + const cars = [] + for await (const car of splitter.cars()) { + cars.push(car) + } + t.true(cars.length >= 2) + const chunkedBlocks = [] + for (const c of cars) { + const bs = await collect((await CarReader.fromIterable(c)).blocks()) + chunkedBlocks.push(...bs) + } + t.is(blocks.length, chunkedBlocks.length) +})