Skip to content

Commit 336d0b9

Browse files
authored
fix: OOM on large DAGs (#410)
Storing a set of seen CIDs to short-cut DAG traversal while ensuring we have all the blocks in a DAG in the blockstore can cause OOMs for very large DAGs so replace the unbounded `Set` with a `LRU` cache with a configurable maximum size.
1 parent c35a5db commit 336d0b9

File tree

4 files changed

+61
-16
lines changed

4 files changed

+61
-16
lines changed

packages/ipfs-repo-migrations/src/index.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint complexity: ["error", 27] */
1+
/* eslint complexity: ["error", 28] */
22

33
import defaultMigrations from '../migrations/index.js'
44
import * as repoVersion from './repo/version.js'
@@ -43,8 +43,11 @@ export function getLatestMigrationVersion (migrations) {
4343
* @param {number} toVersion - Version to which the repo should be migrated.
4444
* @param {MigrationOptions} [options] - Options for migration
4545
*/
46-
export async function migrate (path, backends, repoOptions, toVersion, { ignoreLock = false, onProgress, isDryRun = false, migrations }) {
47-
migrations = migrations || defaultMigrations
46+
export async function migrate (path, backends, repoOptions, toVersion, options = {}) {
47+
const ignoreLock = options.ignoreLock ?? false
48+
const onProgress = options.onProgress
49+
const isDryRun = options.isDryRun ?? false
50+
const migrations = options.migrations ?? defaultMigrations
4851

4952
if (!path) {
5053
throw new errors.RequiredParameterError('Path argument is required!')
@@ -143,8 +146,11 @@ export async function migrate (path, backends, repoOptions, toVersion, { ignoreL
143146
* @param {number} toVersion - Version to which the repo will be reverted.
144147
* @param {MigrationOptions} [options] - Options for the reversion
145148
*/
146-
export async function revert (path, backends, repoOptions, toVersion, { ignoreLock = false, onProgress, isDryRun = false, migrations }) {
147-
migrations = migrations || defaultMigrations
149+
export async function revert (path, backends, repoOptions, toVersion, options = {}) {
150+
const ignoreLock = options.ignoreLock ?? false
151+
const onProgress = options.onProgress
152+
const isDryRun = options.isDryRun ?? false
153+
const migrations = options.migrations ?? defaultMigrations
148154

149155
if (!path) {
150156
throw new errors.RequiredParameterError('Path argument is required!')

packages/ipfs-repo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
"multiformats": "^9.0.4",
205205
"p-queue": "^7.3.0",
206206
"proper-lockfile": "^4.0.0",
207+
"quick-lru": "^6.1.1",
207208
"sort-keys": "^5.0.0",
208209
"uint8arrays": "^3.0.0"
209210
},

packages/ipfs-repo/src/pin-manager.js

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ import {
1313
} from './utils/blockstore.js'
1414
import { walkDag } from './utils/walk-dag.js'
1515
import { PinTypes } from './pin-types.js'
16+
import QuickLRU from 'quick-lru'
17+
18+
/**
19+
* @typedef {import('./types').PinType} PinType
20+
* @typedef {import('./types').PinQueryType} PinQueryType
21+
* @typedef {import('multiformats/codecs/interface').BlockCodec<any, any>} BlockCodec
22+
* @typedef {import('./types').PinOptions} PinOptions
23+
* @typedef {import('./types').AbortOptions} AbortOptions
24+
* @typedef {import('./types').Pins} Pins
25+
*/
1626

1727
/**
1828
* @typedef {object} PinInternal
@@ -23,14 +33,13 @@ import { PinTypes } from './pin-types.js'
2333
*/
2434

2535
/**
26-
* @typedef {import('./types').PinType} PinType
27-
* @typedef {import('./types').PinQueryType} PinQueryType
28-
* @typedef {import('multiformats/codecs/interface').BlockCodec<any, any>} BlockCodec
29-
* @typedef {import('./types').PinOptions} PinOptions
30-
* @typedef {import('./types').AbortOptions} AbortOptions
31-
* @typedef {import('./types').Pins} Pins
36+
* @typedef {object} FetchCompleteDagOptions
37+
* @property {AbortSignal} [signal]
38+
* @property {number} [cidCacheMaxSize]
3239
*/
3340

41+
const CID_CACHE_MAX_SIZE = 2048
42+
3443
/**
3544
* @param {string} type
3645
*/
@@ -95,7 +104,7 @@ export class PinManager {
95104

96105
/**
97106
* @param {CID} cid
98-
* @param {PinOptions & AbortOptions} [options]
107+
* @param {PinOptions & FetchCompleteDagOptions & AbortOptions} [options]
99108
*/
100109
async pinRecursively (cid, options = {}) {
101110
await this.fetchCompleteDag(cid, options)
@@ -271,10 +280,10 @@ export class PinManager {
271280

272281
/**
273282
* @param {CID} cid
274-
* @param {AbortOptions} options
283+
* @param {FetchCompleteDagOptions} [options]
275284
*/
276-
async fetchCompleteDag (cid, options) {
277-
const seen = new Set()
285+
async fetchCompleteDag (cid, options = {}) {
286+
const seen = new QuickLRU({ maxSize: options.cidCacheMaxSize ?? CID_CACHE_MAX_SIZE })
278287

279288
/**
280289
* @param {CID} cid
@@ -285,7 +294,7 @@ export class PinManager {
285294
return
286295
}
287296

288-
seen.add(cid.toString())
297+
seen.set(cid.toString(), true)
289298

290299
const bytes = await this.blockstore.get(cid, options)
291300
const codec = await this.loadCodec(cid.code)

packages/ipfs-repo/test/pins-test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid'
99
import all from 'it-all'
1010
import { PinTypes } from '../src/pin-types.js'
1111
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
12+
import Sinon from 'sinon'
1213

1314
/**
1415
* @param {import('@ipld/dag-pb').PBNode} node
@@ -105,6 +106,34 @@ export default (repo) => {
105106
expect(pins.filter(p => p.cid.toString() === cid.toString()))
106107
.to.have.deep.nested.property('[0].metadata', metadata)
107108
})
109+
110+
it('does not traverse the same linked node twice', async () => {
111+
// @ts-expect-error blockstore property is private
112+
const getSpy = Sinon.spy(repo.pins.blockstore, 'get')
113+
114+
const { cid: childCid, buf: childBuf } = await createDagPbNode()
115+
await repo.blocks.put(childCid, childBuf)
116+
117+
// create a root block with duplicate links to the same block
118+
const { cid: rootCid, buf: rootBuf } = await createDagPbNode({
119+
Links: [{
120+
Name: 'child-1',
121+
Tsize: childBuf.byteLength,
122+
Hash: childCid
123+
}, {
124+
Name: 'child-2',
125+
Tsize: childBuf.byteLength,
126+
Hash: childCid
127+
}]
128+
})
129+
await repo.blocks.put(rootCid, rootBuf)
130+
131+
await repo.pins.pinRecursively(rootCid)
132+
133+
expect(getSpy.callCount).to.equal(2, 'should only have loaded the child block once')
134+
expect(getSpy.getCall(0).args[0]).to.deep.equal(rootCid)
135+
expect(getSpy.getCall(1).args[0]).to.deep.equal(childCid)
136+
})
108137
})
109138

110139
describe('.unpin', () => {

0 commit comments

Comments
 (0)