Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(http): Implement soft and hard TTL for all cache providers #33904

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions lib/modules/platform/github/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ describe('modules/platform/github/index', () => {
token: '123test',
});

const repoCache = repository.getCache();
delete repoCache.platform;
delete process.env.RENOVATE_X_GITHUB_HOST_RULES;
repository.resetCache();
});

describe('initPlatform()', () => {
Expand Down
37 changes: 32 additions & 5 deletions lib/util/http/cache/abstract-http-cache-provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DateTime } from 'luxon';
import { logger } from '../../../logger';
import { HttpCacheStats } from '../../stats';
import type { GotOptions, HttpResponse } from '../types';
Expand All @@ -6,6 +7,9 @@
import type { HttpCacheProvider } from './types';

export abstract class AbstractHttpCacheProvider implements HttpCacheProvider {
protected abstract softTtlMinutes: number;
protected abstract hardTtlMinutes: number;

protected abstract load(url: string): Promise<unknown>;
protected abstract persist(url: string, data: HttpCache): Promise<void>;

Expand All @@ -19,10 +23,14 @@
return httpCache as HttpCache;
}

async setCacheHeaders<T extends Pick<GotOptions, 'headers'>>(
async setCacheHeaders<T extends Pick<GotOptions, 'headers' | 'method'>>(
url: string,
opts: T,
): Promise<void> {
if (opts.method?.toLowerCase() !== 'get') {
return;

Check warning on line 31 in lib/util/http/cache/abstract-http-cache-provider.ts

View check run for this annotation

Codecov / codecov/patch

lib/util/http/cache/abstract-http-cache-provider.ts#L31

Added line #L31 was not covered by tests
}

const httpCache = await this.get(url);
if (!httpCache) {
return;
Expand All @@ -39,11 +47,29 @@
}
}

bypassServer<T>(
_url: string,
_ignoreSoftTtl: boolean,
async bypassServer<T>(
url: string,
ignoreSoftTtl: boolean,
): Promise<HttpResponse<T> | null> {
return Promise.resolve(null);
const cached = await this.get(url);
if (!cached) {
return null;
}

if (ignoreSoftTtl) {
return cached.httpResponse as HttpResponse<T>;
}

const cachedAt = DateTime.fromISO(cached.timestamp);
const deadline = cachedAt.plus({ minutes: this.softTtlMinutes });
const now = DateTime.now();
if (now >= deadline) {
HttpCacheStats.incLocalMisses(url);
return null;
}

HttpCacheStats.incLocalHits(url);
return cached.httpResponse as HttpResponse<T>;
}

async wrapServerResponse<T>(
Expand All @@ -65,6 +91,7 @@
httpResponse,
timestamp,
});
/* istanbul ignore else */
if (newHttpCache) {
logger.debug(
`http cache: saving ${url} (etag=${etag}, lastModified=${lastModified})`,
Expand Down
34 changes: 4 additions & 30 deletions lib/util/http/cache/package-http-cache-provider.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { DateTime } from 'luxon';
import { get, set } from '../../cache/package'; // Import the package cache functions
import { resolveTtlValues } from '../../cache/package/ttl';
import type { PackageCacheNamespace } from '../../cache/package/types';
import { HttpCacheStats } from '../../stats';
import type { HttpResponse } from '../types';
import { AbstractHttpCacheProvider } from './abstract-http-cache-provider';
import type { HttpCache } from './schema';

Expand All @@ -15,12 +12,14 @@ export interface PackageHttpCacheProviderOptions {
export class PackageHttpCacheProvider extends AbstractHttpCacheProvider {
private namespace: PackageCacheNamespace;

private softTtlMinutes: number;
private hardTtlMinutes: number;
protected override softTtlMinutes: number;
protected override hardTtlMinutes: number;

constructor({ namespace, ttlMinutes = 15 }: PackageHttpCacheProviderOptions) {
super();

this.namespace = namespace;

const { softTtlMinutes, hardTtlMinutes } = resolveTtlValues(
this.namespace,
ttlMinutes,
Expand All @@ -36,29 +35,4 @@ export class PackageHttpCacheProvider extends AbstractHttpCacheProvider {
async persist(url: string, data: HttpCache): Promise<void> {
await set(this.namespace, url, data, this.hardTtlMinutes);
}

override async bypassServer<T>(
url: string,
ignoreSoftTtl = false,
): Promise<HttpResponse<T> | null> {
const cached = await this.get(url);
if (!cached) {
return null;
}

if (ignoreSoftTtl) {
return cached.httpResponse as HttpResponse<T>;
}

const cachedAt = DateTime.fromISO(cached.timestamp);
const deadline = cachedAt.plus({ minutes: this.softTtlMinutes });
const now = DateTime.now();
if (now >= deadline) {
HttpCacheStats.incLocalMisses(url);
return null;
}

HttpCacheStats.incLocalHits(url);
return cached.httpResponse as HttpResponse<T>;
}
}
14 changes: 0 additions & 14 deletions lib/util/http/cache/repository-http-cache-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Http } from '..';
import * as httpMock from '../../../../test/http-mock';
import { logger } from '../../../../test/util';
import { resetCache } from '../../cache/repository';
import { repoCacheProvider } from './repository-http-cache-provider';

Expand All @@ -13,7 +12,7 @@
cacheProvider: repoCacheProvider,
});

it('reuses data with etag', async () => {

Check failure on line 15 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › reuses data with etag

*** Unused HTTP mocks *** - GET https://example.com/foo/bar Requests done: - GET https://example.com/foo/bar [200] --- Renovate testing strategy requires that every HTTP request has a corresponding mock. This error occurs because some of the created mocks are unused. In most cases, you simply need to remove them. at <test> (lib/util/http/cache/repository-http-cache-provider.spec.ts:15:7)
const scope = httpMock.scope('https://example.com');

scope.get('/foo/bar').reply(200, { msg: 'Hello, world!' }, { etag: '123' });
Expand All @@ -26,14 +25,14 @@

scope.get('/foo/bar').reply(304);
const res2 = await http.getJsonUnchecked('https://example.com/foo/bar');
expect(res2).toMatchObject({

Check failure on line 28 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › reuses data with etag

expect(received).toMatchObject(expected) - Expected - 1 + Received + 0 Object { - "authorization": false, "body": Object { "msg": "Hello, world!", }, "statusCode": 200, } at Object.<anonymous> (lib/util/http/cache/repository-http-cache-provider.spec.ts:28:18)
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
});

it('reuses data with last-modified', async () => {

Check failure on line 35 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › reuses data with last-modified

*** Unused HTTP mocks *** - GET https://example.com/foo/bar Requests done: - GET https://example.com/foo/bar [200] --- Renovate testing strategy requires that every HTTP request has a corresponding mock. This error occurs because some of the created mocks are unused. In most cases, you simply need to remove them. at <test> (lib/util/http/cache/repository-http-cache-provider.spec.ts:35:7)
const scope = httpMock.scope('https://example.com');

scope
Expand All @@ -52,26 +51,13 @@

scope.get('/foo/bar').reply(304);
const res2 = await http.getJsonUnchecked('https://example.com/foo/bar');
expect(res2).toMatchObject({

Check failure on line 54 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › reuses data with last-modified

expect(received).toMatchObject(expected) - Expected - 1 + Received + 0 Object { - "authorization": false, "body": Object { "msg": "Hello, world!", }, "statusCode": 200, } at Object.<anonymous> (lib/util/http/cache/repository-http-cache-provider.spec.ts:54:18)
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
});

it('reports if cache could not be persisted', async () => {
httpMock
.scope('https://example.com')
.get('/foo/bar')
.reply(200, { msg: 'Hello, world!' });

await http.getJsonUnchecked('https://example.com/foo/bar');

expect(logger.logger.debug).toHaveBeenCalledWith(
'http cache: failed to persist cache for https://example.com/foo/bar',
);
});

it('handles abrupt cache reset', async () => {
const scope = httpMock.scope('https://example.com');

Expand Down Expand Up @@ -105,7 +91,7 @@
});
});

it('supports authorization', async () => {

Check failure on line 94 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › supports authorization

*** Unused HTTP mocks *** - GET https://example.com/foo/bar Requests done: - GET https://example.com/foo/bar [200] --- Renovate testing strategy requires that every HTTP request has a corresponding mock. This error occurs because some of the created mocks are unused. In most cases, you simply need to remove them. at <test> (lib/util/http/cache/repository-http-cache-provider.spec.ts:94:7)
const scope = httpMock.scope('https://example.com');

scope.get('/foo/bar').reply(200, { msg: 'Hello, world!' }, { etag: '123' });
Expand All @@ -122,7 +108,7 @@
const res2 = await http.getJsonUnchecked('https://example.com/foo/bar', {
headers: { authorization: 'Bearer 123' },
});
expect(res2).toMatchObject({

Check failure on line 111 in lib/util/http/cache/repository-http-cache-provider.spec.ts

View workflow job for this annotation

GitHub Actions / test (15/16)

util/http/cache/repository-http-cache-provider › supports authorization

expect(received).toMatchObject(expected) - Expected - 1 + Received + 0 Object { - "authorization": true, "body": Object { "msg": "Hello, world!", }, "statusCode": 200, } at Object.<anonymous> (lib/util/http/cache/repository-http-cache-provider.spec.ts:111:18)
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: true,
Expand Down
3 changes: 3 additions & 0 deletions lib/util/http/cache/repository-http-cache-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { AbstractHttpCacheProvider } from './abstract-http-cache-provider';
import type { HttpCache } from './schema';

export class RepositoryHttpCacheProvider extends AbstractHttpCacheProvider {
protected override softTtlMinutes = 15;
protected override hardTtlMinutes = 365 * 24 * 60;

override load(url: string): Promise<unknown> {
const cache = getCache();
cache.httpCache ??= {};
Expand Down
4 changes: 0 additions & 4 deletions lib/util/http/cache/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ export const HttpCacheSchema = z
httpResponse: z.unknown(),
timestamp: z.string(),
})
.refine(
({ etag, lastModified }) => etag ?? lastModified,
'Cache object should have `etag` or `lastModified` fields',
)
.nullable()
.catch(null);
export type HttpCache = z.infer<typeof HttpCacheSchema>;
Loading