Skip to content

Commit 36f966a

Browse files
committed
Use RefCountedCache for ZipFs instances in Cache
1 parent 3c8a90a commit 36f966a

File tree

4 files changed

+230
-4
lines changed

4 files changed

+230
-4
lines changed

.yarn/versions/1137015d.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
releases:
2+
"@yarnpkg/cli": minor
3+
"@yarnpkg/core": minor
4+
5+
declined:
6+
- "@yarnpkg/plugin-compat"
7+
- "@yarnpkg/plugin-constraints"
8+
- "@yarnpkg/plugin-dlx"
9+
- "@yarnpkg/plugin-essentials"
10+
- "@yarnpkg/plugin-exec"
11+
- "@yarnpkg/plugin-file"
12+
- "@yarnpkg/plugin-git"
13+
- "@yarnpkg/plugin-github"
14+
- "@yarnpkg/plugin-http"
15+
- "@yarnpkg/plugin-init"
16+
- "@yarnpkg/plugin-interactive-tools"
17+
- "@yarnpkg/plugin-link"
18+
- "@yarnpkg/plugin-nm"
19+
- "@yarnpkg/plugin-npm"
20+
- "@yarnpkg/plugin-npm-cli"
21+
- "@yarnpkg/plugin-pack"
22+
- "@yarnpkg/plugin-patch"
23+
- "@yarnpkg/plugin-pnp"
24+
- "@yarnpkg/plugin-pnpm"
25+
- "@yarnpkg/plugin-stage"
26+
- "@yarnpkg/plugin-typescript"
27+
- "@yarnpkg/plugin-version"
28+
- "@yarnpkg/plugin-workspace-tools"
29+
- "@yarnpkg/builder"
30+
- "@yarnpkg/doctor"
31+
- "@yarnpkg/extensions"
32+
- "@yarnpkg/nm"
33+
- "@yarnpkg/pnpify"
34+
- "@yarnpkg/sdks"

packages/yarnpkg-core/sources/Cache.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from 'fs';
77

88
import {Configuration} from './Configuration';
99
import {MessageName} from './MessageName';
10+
import {RefCountedCache} from './RefCountedCache';
1011
import {ReportError} from './Report';
1112
import * as hashUtils from './hashUtils';
1213
import * as miscUtils from './miscUtils';
@@ -72,6 +73,8 @@ export class Cache {
7273
checksum: string | null,
7374
]>> = new Map();
7475

76+
private refCountedZipFsCache = new RefCountedCache<string, ZipFS>(zipFs => zipFs.discardAndClose);
77+
7578
/**
7679
* To ensure different instances of `Cache` doesn't end up copying to the same
7780
* temporary file this random ID is appended to the filename.
@@ -467,14 +470,23 @@ export class Cache {
467470
if (!shouldMock)
468471
this.markedFiles.add(cachePath);
469472

470-
let zipFs: ZipFS | undefined;
473+
let releaseCacheEntry: () => void | undefined;
471474

472475
const zipFsBuilder = shouldMock
473476
? () => makeMockPackage()
474-
: () => new ZipFS(cachePath, {baseFs, readOnly: true});
477+
: () => {
478+
const result = this.refCountedZipFsCache.addOrCreate(cachePath, () => {
479+
return new ZipFS(cachePath, {baseFs, readOnly: true});
480+
});
481+
if (releaseCacheEntry !== undefined)
482+
throw new Error(`Race condition in ZipFsBuild & RefCountedZipFsCache`);
483+
484+
releaseCacheEntry = result.release;
485+
return result.value;
486+
};
475487

476488
const lazyFs = new LazyFS<PortablePath>(() => miscUtils.prettifySyncErrors(() => {
477-
return zipFs = zipFsBuilder();
489+
return zipFsBuilder();
478490
}, message => {
479491
return `Failed to open the cache entry for ${structUtils.prettyLocator(this.configuration, locator)}: ${message}`;
480492
}), ppath);
@@ -484,7 +496,9 @@ export class Cache {
484496
const aliasFs = new AliasFS(cachePath, {baseFs: lazyFs, pathUtils: ppath});
485497

486498
const releaseFs = () => {
487-
zipFs?.discardAndClose();
499+
if (releaseCacheEntry) {
500+
releaseCacheEntry();
501+
}
488502
};
489503

490504
// We hide the checksum if the package presence is conditional, because it becomes unreliable
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* A cache map with reference counting. This map is designed to handle
3+
* a resource that has native/wasm handles which need to be release explicitly.
4+
* It also requires the value to have a unique map to cache instanches
5+
*/
6+
export class RefCountedCache<TKey, TValue> {
7+
private map: Map<TKey, {value: TValue, refCount: number}>;
8+
9+
/**
10+
* Creates a new RefCountedMap.
11+
* @param releaseFunction The function to release the native resources.
12+
*/
13+
constructor(private releaseFunction: (value: TValue) => void) {
14+
this.map = new Map<TKey, {value: TValue, refCount: number}>();
15+
}
16+
17+
/**
18+
*
19+
* @param key A unique key to indentify the instance in this Map
20+
* @param createInstance The function to create a new instance of TValue if none already esists
21+
* @returns The value form the cache (or newly created when not present) as well as the release function
22+
* to call when the object is to be released.
23+
*/
24+
addOrCreate(key: TKey, createInstance: () => TValue): {value: TValue, release: () => void} {
25+
const result = this.map.get(key);
26+
if (result) {
27+
// We had an existing value.
28+
if (result.refCount <= 0)
29+
throw new Error(`Race condition in RefCountedMap. While adding a new key the refCount is: ${result.refCount} for ${JSON.stringify(key)}`);
30+
31+
32+
// Update the map with one extra refcount value
33+
this.map.set(key, {refCount: result.refCount + 1, value: result.value});
34+
return {
35+
value: result.value,
36+
release: () => this.release(key),
37+
};
38+
} else {
39+
// we had not seen this before, create the instance and register a refCount of 1.
40+
const newValue = createInstance();
41+
this.map.set(key, {refCount: 1, value: newValue});
42+
return {
43+
value: newValue,
44+
release: () => this.release(key),
45+
};
46+
}
47+
}
48+
49+
/**
50+
* Releases the object by decreasing the refcount. When the last reference is released (i.e. the refcount goes to 0)
51+
* This function will call to the releaseFunction passed to the cache map to release the native resources.
52+
*/
53+
private release(key: TKey): void {
54+
const result = this.map.get(key);
55+
if (!result)
56+
throw new Error(`Unbalanced calls to release. No known instances of: ${JSON.stringify(key)}`);
57+
58+
59+
const newRefCount = result.refCount - 1;
60+
if (newRefCount < 0) {
61+
throw new Error(`Unbalanced calls to release. Too many release vs alloc refcount would be: ${newRefCount} of ${JSON.stringify(key)}`);
62+
} else if (newRefCount == 0) {
63+
// This was the last used item. Remove the value from the map and call the releaseFunction to free the native resources
64+
this.map.delete(key);
65+
this.releaseFunction(result.value);
66+
} else {
67+
// overwrite the entry with one less refcount.
68+
this.map.set(key, {refCount: newRefCount, value: result.value});
69+
}
70+
}
71+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {RefCountedCache} from '../sources/RefCountedCache';
2+
3+
describe(`RefCountedCache`, () => {
4+
it(`should create value on first create`, () => {
5+
const actions: Array<string> = [];
6+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
7+
const result = cache.addOrCreate(`a`, () => {
8+
const result = `create a-1`; actions.push(result); return result;
9+
}) ;
10+
expect(result.value).toBe(`create a-1`);
11+
expect(actions).toStrictEqual([`create a-1`]);
12+
});
13+
14+
it(`should release single value`, () => {
15+
const actions: Array<string> = [];
16+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
17+
const result = cache.addOrCreate(`a`, () => {
18+
const result = `create a-1`; actions.push(result); return result;
19+
}) ;
20+
result.release();
21+
expect(actions).toStrictEqual([`create a-1`, `release create a-1`]);
22+
});
23+
24+
it(`should return first created value and only release on the last value`, () => {
25+
const actions: Array<string> = [];
26+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
27+
const result1 = cache.addOrCreate(`a`, () => {
28+
const result = `create a-1`; actions.push(result); return result;
29+
}) ;
30+
expect(result1.value).toBe(`create a-1`);
31+
expect(actions).toStrictEqual([`create a-1`]);
32+
33+
// Creating new value with same key should reuse the previous value.
34+
const result2 = cache.addOrCreate(`a`, () => {
35+
const result = `create a-2`; actions.push(result); return result;
36+
}) ;
37+
expect(result2.value).toBe(`create a-1`);
38+
expect(actions).toStrictEqual([`create a-1`]);
39+
40+
// releasing one should not call release function
41+
result1.release();
42+
expect(actions).toStrictEqual([`create a-1`]);
43+
44+
// releasing second should call release, but on the first created instance.
45+
result2.release();
46+
expect(actions).toStrictEqual([`create a-1`, `release create a-1`]);
47+
});
48+
49+
it(`should handle multiple keys single value`, () => {
50+
const actions: Array<string> = [];
51+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
52+
const result1 = cache.addOrCreate(`a`, () => {
53+
const result = `create a-1`; actions.push(result); return result;
54+
}) ;
55+
result1.release();
56+
const result2 = cache.addOrCreate(`b`, () => {
57+
const result = `create b-2`; actions.push(result); return result;
58+
}) ;
59+
result2.release();
60+
const result3 = cache.addOrCreate(`c`, () => {
61+
const result = `create c-3`; actions.push(result); return result;
62+
}) ;
63+
cache.addOrCreate(`d`, () => {
64+
const result = `create d-4`; actions.push(result); return result;
65+
}) ;
66+
const result5 = cache.addOrCreate(`e`, () => {
67+
const result = `create e-5`; actions.push(result); return result;
68+
}) ;
69+
result5.release();
70+
// skipping release 4 release
71+
result3.release();
72+
73+
expect(actions).toStrictEqual([`create a-1`, `release create a-1`, `create b-2`, `release create b-2`, `create c-3`, `create d-4`, `create e-5`, `release create e-5`, `release create c-3`]);
74+
});
75+
76+
it(`should can create new instances after removing releasing value`, () => {
77+
const actions: Array<string> = [];
78+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
79+
const result1 = cache.addOrCreate(`a`, () => {
80+
const result = `create a-1`; actions.push(result); return result;
81+
}) ;
82+
const result2 = cache.addOrCreate(`a`, () => {
83+
const result = `create a-2`; actions.push(result); return result;
84+
}) ;
85+
result1.release();
86+
result2.release();
87+
const result3 = cache.addOrCreate(`a`, () => {
88+
const result = `create a-3`; actions.push(result); return result;
89+
}) ;
90+
const result4 = cache.addOrCreate(`a`, () => {
91+
const result = `create a-4`; actions.push(result); return result;
92+
}) ;
93+
result4.release();
94+
result3.release();
95+
expect(actions).toStrictEqual([`create a-1`, `release create a-1`, `create a-3`, `release create a-3`]);
96+
});
97+
98+
it(`should throw when releasing too many times`, () => {
99+
const actions: Array<string> = [];
100+
const cache = new RefCountedCache<string, string>((id => actions.push(`release ${id}`)));
101+
const result1 = cache.addOrCreate(`a`, () => {
102+
const result = `create a-1`; actions.push(result); return result;
103+
}) ;
104+
result1.release();
105+
expect(result1.release).toThrow(/No known instances of: "a"/);
106+
});
107+
});

0 commit comments

Comments
 (0)