Skip to content

Commit

Permalink
feat(kernel): experimental runtime package cache
Browse files Browse the repository at this point in the history
Adds an experimental (hence opt-in) feature that caches the contents of
loaded libraries in a directory that persists between executions, in
order to spare the time it takes to extract the tarballs.

When this feature is enabled, packages present in the cache will be used
as-is (i.e: they are not checked for tampering) instead of being
extracted from the tarball. The cache is keyed on:
- The hash of the tarball
- The name of the library
- The version of the library

Objects in the cache will expire if they are not used for 30 days, and
are subsequently removed from disk (this avoids a cache growing
extremely large over time).

In order to enable the feature, the following environment variables are
used:
- `JSII_RUNTIME_PACKAGE_CACHE` must be set to `enabled` in order for the
  package cache to be active at all;
- `JSII_RUNTIME_PACKAGE_CACHE_ROOT` can be used to change which
  directory is used as a cache root. It defaults to:
  * On MacOS: `$HOME/Library/Caches/com.amazonaws.jsii`
  * On Linux: `$HOME/.cache/aws/jsii/package-cache`
  * On Windows: `%LOCALAPPDATA%\AWS\jsii\package-cache`
  * On other platforms: `$TMP/aws-jsii-package-cache`
- `JSII_RUNTIME_PACKAGE_CACHE_TTL` can be used to change the default
  time entries will remain in cache before expiring if they are not
  used. This defaults to 30 days, and the value is expressed in days.
  Set to `0` to immediately expire all the cache's content.

When troubleshooting load performance, it is possible to obtain timing
data for some critical parts of the library load process within the jsii
kernel by setting `JSII_DEBUG_TIMING` environment variable.

Related to #3389
  • Loading branch information
RomainMuller committed Aug 26, 2022
1 parent 93aec85 commit 5dc816e
Show file tree
Hide file tree
Showing 12 changed files with 528 additions and 24 deletions.
2 changes: 2 additions & 0 deletions packages/@jsii/kernel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
"dependencies": {
"@jsii/spec": "^0.0.0",
"fs-extra": "^10.1.0",
"lockfile": "^1.0.4",
"tar": "^6.1.11"
},
"devDependencies": {
"@scope/jsii-calc-base": "^0.0.0",
"@scope/jsii-calc-lib": "^0.0.0",
"@types/fs-extra": "^9.0.13",
"@types/lockfile": "^1.0.2",
"@types/tar": "^6.1.2",
"jest-expect-message": "^1.0.2",
"jsii-build-tools": "^0.0.0",
Expand Down
11 changes: 11 additions & 0 deletions packages/@jsii/kernel/src/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
} from './api';
import { Kernel } from './kernel';
import { closeRecording, recordInteraction } from './recording';
import * as tar from './tar-cache';
import { defaultCacheRoot } from './tar-cache/default-cache-root';
import { DiskCache } from './tar-cache/disk-cache';

/* eslint-disable require-atomic-updates */

Expand Down Expand Up @@ -49,6 +52,11 @@ if (recordingOutput) {
console.error(`JSII_RECORD=${recordingOutput}`);
}

afterAll(() => {
// Jest prevents execution of "beforeExit" events.
DiskCache.inDirectory(defaultCacheRoot()).pruneExpiredEntries();
});

function defineTest(
name: string,
method: (sandbox: Kernel) => Promise<any> | any,
Expand Down Expand Up @@ -2147,6 +2155,9 @@ defineTest('invokeBinScript() return output', (sandbox) => {
const testNames: { [name: string]: boolean } = {};

async function createCalculatorSandbox(name: string) {
// Run half the tests with cache, half without cache... so we test both.
tar.setPackageCacheEnabled(!tar.getPackageCacheEnabled());

if (name in testNames) {
throw new Error(`Duplicate test name: ${name}`);
}
Expand Down
90 changes: 74 additions & 16 deletions packages/@jsii/kernel/src/kernel.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import * as spec from '@jsii/spec';
import { loadAssemblyFromPath } from '@jsii/spec';
import * as cp from 'child_process';
import { renameSync } from 'fs';
import * as fs from 'fs-extra';
import { createRequire } from 'module';
import * as os from 'os';
import * as path from 'path';
import * as tar from 'tar';

import * as api from './api';
import { TOKEN_REF } from './api';
import { link } from './link';
import { jsiiTypeFqn, ObjectTable, tagJsiiConstructor } from './objects';
import * as onExit from './on-exit';
import * as wire from './serialization';
import * as tar from './tar-cache';

export class Kernel {
/**
* Set to true for verbose debugging.
*/
public traceEnabled = false;
/**
* Set to true for timing data to be emitted.
*/
public debugTimingEnabled = false;

private readonly assemblies = new Map<string, Assembly>();
private readonly objects = new ObjectTable(this._typeInfoForFqn.bind(this));
Expand All @@ -40,6 +46,13 @@ export class Kernel {
public constructor(public callbackHandler: (callback: api.Callback) => any) {}

public load(req: api.LoadRequest): api.LoadResponse {
return this._debugTime(
() => this._load(req),
`load(${JSON.stringify(req, null, 2)})`,
);
}

private _load(req: api.LoadRequest): api.LoadResponse {
this._debug('load', req);

if ('assembly' in req) {
Expand Down Expand Up @@ -74,21 +87,41 @@ export class Kernel {
};
}

// Create the install directory (there may be several path components for @scoped/packages)
fs.mkdirpSync(packageDir);

// Force umask to have npm-install-like permissions
const originalUmask = process.umask(0o022);
try {
// untar the archive to its final location
tar.extract({
cwd: packageDir,
file: req.tarball,
strict: true,
strip: 1, // Removes the 'package/' path element from entries
sync: true,
unlink: true,
});
const { path: extractedTo, cache } = this._debugTime(
() =>
tar.extract(
req.tarball,
{
strict: true,
strip: 1, // Removes the 'package/' path element from entries
unlink: true,
},
req.name,
req.version,
),
`tar.extract(${req.tarball}) => ${packageDir}`,
);

// Create the install directory (there may be several path components for @scoped/packages)
fs.mkdirSync(path.dirname(packageDir), { recursive: true });
if (cache != null) {
this._debug(
`Package cache enabled, extraction resulted in a cache ${cache}`,
);

// Link the package into place.
this._debugTime(
() => link(extractedTo, packageDir),
`link(${extractedTo}, ${packageDir})`,
);
} else {
// This is not from cache, so we move it around instead of copying.
renameSync(extractedTo, packageDir);
}
} finally {
// Reset umask to the initial value
process.umask(originalUmask);
Expand All @@ -97,15 +130,26 @@ export class Kernel {
// read .jsii metadata from the root of the package
let assmSpec;
try {
assmSpec = loadAssemblyFromPath(packageDir);
assmSpec = this._debugTime(
() => loadAssemblyFromPath(packageDir),
`loadAssemblyFromPath(${packageDir})`,
);
} catch (e: any) {
throw new Error(`Error for package tarball ${req.tarball}: ${e.message}`);
}

// load the module and capture its closure
const closure = this.require!(packageDir);
const closure = this._debugTime(
() => this.require!(packageDir),
`require(${packageDir})`,
);
const assm = new Assembly(assmSpec, closure);
this._addAssembly(assm);
this._debugTime(
() => this._addAssembly(assm),
`registerAssembly({ name: ${assm.metadata.name}, types: ${
Object.keys(assm.metadata.types ?? {}).length
} })`,
);

return {
assembly: assmSpec.name,
Expand Down Expand Up @@ -511,7 +555,7 @@ export class Kernel {
case spec.TypeKind.Class:
case spec.TypeKind.Enum:
const constructor = this._findSymbol(fqn);
tagJsiiConstructor(constructor, fqn);
tagJsiiConstructor(constructor, fqn, assm.metadata.version);
}
}
}
Expand Down Expand Up @@ -1212,6 +1256,20 @@ export class Kernel {
}
}

private _debugTime<T>(cb: () => T, label: string): T {
const fullLabel = `[@jsii/kernel:timing] ${label}`;
if (this.debugTimingEnabled) {
console.time(fullLabel);
}
try {
return cb();
} finally {
if (this.debugTimingEnabled) {
console.timeEnd(fullLabel);
}
}
}

/**
* Ensures that `fn` is called and defends against beginning to invoke
* async methods until fn finishes (successfully or not).
Expand Down
26 changes: 26 additions & 0 deletions packages/@jsii/kernel/src/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { copyFileSync, linkSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join } from 'path';

/**
* Creates directories containing hard links if possible, and falls back on
* copy otherwise.
*
* @param existing is the original file or directory to link.
* @param destination is the nbew file or directory to create.
*/
export function link(existing: string, destination: string): void {
const stat = statSync(existing);
if (!stat.isDirectory()) {
try {
linkSync(existing, destination);
} catch {
copyFileSync(existing, destination);
}
return;
}

mkdirSync(destination, { recursive: true });
for (const file of readdirSync(existing)) {
link(join(existing, file), join(destination, file));
}
}
26 changes: 21 additions & 5 deletions packages/@jsii/kernel/src/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ const IFACES_SYMBOL = Symbol.for('$__jsii__interfaces__$');
/**
* Symbol we use to tag the constructor of a JSII class
*/
const JSII_SYMBOL = Symbol.for('__jsii__');
const JSII_RTTI_SYMBOL = Symbol.for('jsii.rtti');

interface ManagedConstructor {
readonly [JSII_RTTI_SYMBOL]: {
readonly fqn: string;
readonly version: string;
};
}

type MaybeManagedConstructor = Partial<ManagedConstructor>;

/**
* Get the JSII fqn for an object (if available)
Expand All @@ -26,7 +35,7 @@ const JSII_SYMBOL = Symbol.for('__jsii__');
* information.
*/
export function jsiiTypeFqn(obj: any): string | undefined {
return obj.constructor[JSII_SYMBOL]?.fqn;
return (obj.constructor as MaybeManagedConstructor)[JSII_RTTI_SYMBOL]?.fqn;
}

/**
Expand Down Expand Up @@ -86,12 +95,19 @@ function tagObject(obj: unknown, objid: string, interfaces?: string[]) {
/**
* Set the JSII FQN for classes produced by a given constructor
*/
export function tagJsiiConstructor(constructor: any, fqn: string) {
Object.defineProperty(constructor, JSII_SYMBOL, {
export function tagJsiiConstructor(
constructor: any,
fqn: string,
version: string,
) {
if (Object.prototype.hasOwnProperty.call(constructor, JSII_RTTI_SYMBOL)) {
return;
}
Object.defineProperty(constructor, JSII_RTTI_SYMBOL, {
configurable: false,
enumerable: false,
writable: false,
value: { fqn },
value: { fqn, version },
});
}

Expand Down
27 changes: 27 additions & 0 deletions packages/@jsii/kernel/src/tar-cache/default-cache-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { tmpdir } from 'os';
import { join } from 'path';

export function defaultCacheRoot(): string {
switch (process.platform) {
case 'darwin':
if (process.env.HOME)
return join(
process.env.HOME,
'Library',
'Caches',
'com.amazonaws.jsii',
);
break;
case 'linux':
if (process.env.HOME)
return join(process.env.HOME, '.cache', 'aws', 'jsii', 'package-cache');
break;
case 'win32':
if (process.env.LOCALAPPDATA)
return join(process.env.LOCALAPPDATA, 'AWS', 'jsii', 'package-cache');
break;
default:
// Fall back on putting in tmpdir()
}
return join(tmpdir(), 'aws-jsii-package-cache');
}
27 changes: 27 additions & 0 deletions packages/@jsii/kernel/src/tar-cache/digest-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createHash } from 'crypto';
import { openSync, readSync, closeSync } from 'fs';

const ALGORITHM = 'sha256';

export function digestFile(
path: string,
...comments: readonly string[]
): Buffer {
const hash = createHash(ALGORITHM);

const buffer = Buffer.alloc(16_384);
const fd = openSync(path, 'r');
try {
let bytesRead = 0;
while ((bytesRead = readSync(fd, buffer)) > 0) {
hash.update(buffer.slice(0, bytesRead));
}
for (const comment of comments) {
hash.update('\0');
hash.update(comment);
}
return hash.digest();
} finally {
closeSync(fd);
}
}
Loading

0 comments on commit 5dc816e

Please sign in to comment.