Skip to content

Commit

Permalink
feat(datasource/npm): cache etag for reuse (#19823)
Browse files Browse the repository at this point in the history
  • Loading branch information
rarkins committed Jan 13, 2023
1 parent d6452f0 commit 78b8483
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 9 deletions.
11 changes: 11 additions & 0 deletions docs/usage/self-hosted-configuration.md
Expand Up @@ -200,6 +200,17 @@ For example:
}
```

## cacheHardTtlMinutes

This experimental feature is used to implement the concept of a "soft" cache expiry for datasources, starting with `npm`.
It should be set to a non-zero value, recommended to be at least 60 (i.e. one hour).

When this value is set, the `npm` datasource will use the `cacheHardTtlMinutes` value for cache expiry, instead of its default expiry of 15 minutes, which becomes the "soft" expiry value.
Results which are soft expired are reused in the following manner:

- The `etag` from the cached results will be reused, and may result in a 304 response, meaning cached results are revalidated
- If an error occurs when querying the `npmjs` registry, then soft expired results will be reused if they are present

## containerbaseDir

This directory is used to cache downloads when `binarySource=docker` or `binarySource=install`.
Expand Down
1 change: 1 addition & 0 deletions lib/config/global.ts
Expand Up @@ -10,6 +10,7 @@ export class GlobalConfig {
'allowScripts',
'binarySource',
'cacheDir',
'cacheHardTtlMinutes',
'containerbaseDir',
'customEnvVariables',
'dockerChildPrefix',
Expand Down
10 changes: 10 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -2217,6 +2217,16 @@ const options: RenovateOptions[] = [
env: false,
experimental: true,
},
{
name: 'cacheHardTtlMinutes',
description:
'Maximum duration in minutes to keep datasource cache entries.',
type: 'integer',
stage: 'repository',
default: 0,
globalOnly: true,
experimental: true,
},
{
name: 'prBodyDefinitions',
description: 'Table column definitions to use in PR tables.',
Expand Down
1 change: 1 addition & 0 deletions lib/config/types.ts
Expand Up @@ -114,6 +114,7 @@ export interface RepoGlobalConfig {
allowScripts?: boolean;
allowedPostUpgradeCommands?: string[];
binarySource?: 'docker' | 'global' | 'install' | 'hermit';
cacheHardTtlMinutes?: number;
customEnvVariables?: Record<string, string>;
dockerChildPrefix?: string;
dockerImagePrefix?: string;
Expand Down
55 changes: 55 additions & 0 deletions lib/modules/datasource/npm/get.spec.ts
@@ -1,10 +1,16 @@
import * as httpMock from '../../../../test/http-mock';
import { mocked } from '../../../../test/util';
import { ExternalHostError } from '../../../types/errors/external-host-error';
import * as _packageCache from '../../../util/cache/package';
import * as hostRules from '../../../util/host-rules';
import { Http } from '../../../util/http';
import { getDependency } from './get';
import { resolveRegistryUrl, setNpmrc } from './npmrc';

jest.mock('../../../util/cache/package');

const packageCache = mocked(_packageCache);

function getPath(s = ''): string {
const [x] = s.split('\n');
const prePath = x.replace(/^.*https:\/\/test\.org/, '');
Expand Down Expand Up @@ -463,4 +469,53 @@ describe('modules/datasource/npm/get', () => {
]
`);
});

it('returns cached legacy', async () => {
packageCache.get.mockResolvedValueOnce({ some: 'result' });
const dep = await getDependency(http, 'https://some.url', 'some-package');
expect(dep).toMatchObject({ some: 'result' });
});

it('returns unexpired cache', async () => {
packageCache.get.mockResolvedValueOnce({
some: 'result',
cacheData: { softExpireAt: '2099' },
});
const dep = await getDependency(http, 'https://some.url', 'some-package');
expect(dep).toMatchObject({ some: 'result' });
});

it('returns soft expired cache if revalidated', async () => {
packageCache.get.mockResolvedValueOnce({
some: 'result',
cacheData: {
softExpireAt: '2020',
etag: 'some-etag',
},
});
setNpmrc('registry=https://test.org\n_authToken=XXX');

httpMock.scope('https://test.org').get('/@neutrinojs%2Freact').reply(304);
const registryUrl = resolveRegistryUrl('@neutrinojs/react');
const dep = await getDependency(http, registryUrl, '@neutrinojs/react');
expect(dep).toMatchObject({ some: 'result' });
});

it('returns soft expired cache on npmjs error', async () => {
packageCache.get.mockResolvedValueOnce({
some: 'result',
cacheData: {
softExpireAt: '2020',
etag: 'some-etag',
},
});

httpMock
.scope('https://registry.npmjs.org')
.get('/@neutrinojs%2Freact')
.reply(500);
const registryUrl = resolveRegistryUrl('@neutrinojs/react');
const dep = await getDependency(http, registryUrl, '@neutrinojs/react');
expect(dep).toMatchObject({ some: 'result' });
});
});
75 changes: 66 additions & 9 deletions lib/modules/datasource/npm/get.ts
@@ -1,13 +1,21 @@
import url from 'url';
import is from '@sindresorhus/is';
import { DateTime } from 'luxon';
import { GlobalConfig } from '../../../config/global';
import { logger } from '../../../logger';
import { ExternalHostError } from '../../../types/errors/external-host-error';
import * as packageCache from '../../../util/cache/package';
import type { Http } from '../../../util/http';
import type { HttpOptions } from '../../../util/http/types';
import { regEx } from '../../../util/regex';
import { joinUrlParts } from '../../../util/url';
import { id } from './common';
import type { NpmDependency, NpmRelease, NpmResponse } from './types';
import type {
CachedNpmDependency,
NpmDependency,
NpmRelease,
NpmResponse,
} from './types';

interface PackageSource {
sourceUrl?: string;
Expand Down Expand Up @@ -56,19 +64,57 @@ export async function getDependency(

// Now check the persistent cache
const cacheNamespace = 'datasource-npm';
const cachedResult = await packageCache.get<NpmDependency>(
const cachedResult = await packageCache.get<CachedNpmDependency>(
cacheNamespace,
packageUrl
);
// istanbul ignore if
if (cachedResult) {
return cachedResult;
if (cachedResult.cacheData) {
const softExpireAt = DateTime.fromISO(
cachedResult.cacheData.softExpireAt
);
if (softExpireAt.isValid && softExpireAt > DateTime.local()) {
logger.trace('Cached result is not expired - reusing');
delete cachedResult.cacheData;
return cachedResult;
}
logger.trace('Cached result is soft expired');
} else {
logger.trace('Reusing legacy cached result');
return cachedResult;
}
}
const cacheMinutes = process.env.RENOVATE_CACHE_NPM_MINUTES
? parseInt(process.env.RENOVATE_CACHE_NPM_MINUTES, 10)
: 15;
const softExpireAt = DateTime.local().plus({ minutes: cacheMinutes }).toISO();
let { cacheHardTtlMinutes } = GlobalConfig.get();
if (!(is.number(cacheHardTtlMinutes) && cacheHardTtlMinutes > cacheMinutes)) {
cacheHardTtlMinutes = cacheMinutes;
}

const uri = url.parse(packageUrl);

try {
const raw = await http.getJson<NpmResponse>(packageUrl);
const options: HttpOptions = {};
if (cachedResult?.cacheData?.etag) {
logger.debug('Using cached etag');
options.headers = { 'If-None-Match': cachedResult.cacheData.etag };
}
const raw = await http.getJson<NpmResponse>(packageUrl, options);
if (cachedResult?.cacheData && raw.statusCode === 304) {
logger.trace('Cached data is unchanged and can be reused');
cachedResult.cacheData.softExpireAt = softExpireAt;
await packageCache.set(
cacheNamespace,
packageUrl,
cachedResult,
cacheHardTtlMinutes
);
delete cachedResult.cacheData;
return cachedResult;
}
const etag = raw.headers.etag;
const res = raw.body;
if (!res.versions || !Object.keys(res.versions).length) {
// Registry returned a 200 OK but with no versions
Expand Down Expand Up @@ -125,9 +171,6 @@ export async function getDependency(
});
logger.trace({ dep }, 'dep');
// serialize first before saving
const cacheMinutes = process.env.RENOVATE_CACHE_NPM_MINUTES
? parseInt(process.env.RENOVATE_CACHE_NPM_MINUTES, 10)
: 15;
// TODO: use dynamic detection of public repos instead of a static list (#9587)
const whitelistedPublicScopes = [
'@graphql-codegen',
Expand All @@ -140,7 +183,13 @@ export async function getDependency(
(whitelistedPublicScopes.includes(packageName.split('/')[0]) ||
!packageName.startsWith('@'))
) {
await packageCache.set(cacheNamespace, packageUrl, dep, cacheMinutes);
const cacheData = { softExpireAt, etag };
await packageCache.set(
cacheNamespace,
packageUrl,
{ ...dep, cacheData },
etag ? cacheHardTtlMinutes : cacheMinutes
);
}
return dep;
} catch (err) {
Expand All @@ -153,6 +202,14 @@ export async function getDependency(
return null;
}
if (uri.host === 'registry.npmjs.org') {
if (cachedResult) {
logger.warn(
{ err },
'npmjs error, reusing expired cached result instead'
);
delete cachedResult.cacheData;
return cachedResult;
}
// istanbul ignore if
if (err.name === 'ParseError' && err.body) {
err.body = 'err.body deleted by Renovate';
Expand Down
7 changes: 7 additions & 0 deletions lib/modules/datasource/npm/types.ts
Expand Up @@ -46,4 +46,11 @@ export interface NpmDependency extends ReleaseResult {
sourceDirectory?: string;
}

export interface CachedNpmDependency extends NpmDependency {
cacheData?: {
etag: string | undefined;
softExpireAt: string;
};
}

export type Npmrc = Record<string, any>;

0 comments on commit 78b8483

Please sign in to comment.