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(github): long-term datasource caching #15653

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b5c4638
feat(github): Prolonged caching for tags and releases
zharinov May 19, 2022
4ffd3dc
WIP
zharinov May 19, 2022
a81da3e
WIP
zharinov May 19, 2022
bbf6ec0
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 19, 2022
17f3201
Fixes
zharinov May 19, 2022
6d827af
Integrate with changelog fetching
zharinov May 20, 2022
3301001
Add separate per-page parameter for cache update
zharinov May 20, 2022
ac3b304
WIP
zharinov May 20, 2022
458fc37
Add tests
zharinov May 22, 2022
bf08b84
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 22, 2022
7a6ceda
Fix tests
zharinov May 22, 2022
5071602
Merge branch 'main' of github.com:renovatebot/renovate into feat/gith…
zharinov May 23, 2022
c1490b5
Fix tests
zharinov May 23, 2022
b514b10
Fix coverage
zharinov May 23, 2022
58bffb4
Refactor/document
zharinov May 23, 2022
f1878f3
Merge branch 'main' of github.com:renovatebot/renovate into feat/gith…
zharinov May 23, 2022
5cb375f
Add more comments
zharinov May 24, 2022
5dd88e4
Fix
zharinov May 24, 2022
0918ec3
Change page limit from 10 to 1000
zharinov May 24, 2022
f273862
Merge branch 'main' of github.com:renovatebot/renovate into feat/gith…
zharinov May 24, 2022
4f0f900
Set max pages allowed to 100
zharinov May 25, 2022
d884c50
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 25, 2022
4060a22
Use spyOn for DateTime
zharinov May 25, 2022
7f9105e
Use spyOn
zharinov May 25, 2022
75e8a4e
Remove unused `updatedAt` field
zharinov May 25, 2022
34773fe
Move types
zharinov May 25, 2022
3e63209
Fix test
zharinov May 25, 2022
a9fcdd3
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 25, 2022
ba5e0e7
Add random delta for hard reset
zharinov May 25, 2022
e26de1d
Refactor a bit
zharinov May 25, 2022
607b28c
Simplify back-off logic
zharinov May 25, 2022
58e4ecd
Merge branch 'main' into feat/github-datasource-long-term-cache
rarkins May 26, 2022
baff301
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 26, 2022
587aa0d
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov May 31, 2022
4eb1195
Merge branch 'main' into feat/github-datasource-long-term-cache
zharinov Jun 2, 2022
b1b4a7b
Merge branch 'main' into feat/github-datasource-long-term-cache
rarkins Jun 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
208 changes: 208 additions & 0 deletions lib/modules/datasource/github-releases/cache-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { DateTime, DurationLike } from 'luxon';
import { logger } from '../../../logger';
import * as packageCache from '../../../util/cache/package';
import type {
GithubGraphqlResponse,
GithubHttp,
} from '../../../util/http/github';
import type { GetReleasesConfig, Release } from '../types';
import { getApiBaseUrl } from './common';

export interface GithubQueryParams {
owner: string;
name: string;
cursor: string | null;
count: number;
}

interface QueryResponse<T extends FetchedItemBase> {
repository: {
payload: {
nodes: T[];
pageInfo: {
hasNextPage: boolean;
endCursor: string;
};
};
};
}

export interface FetchedItemBase {
version: string;
}

export interface StoredItemBase extends FetchedItemBase {
releaseTimestamp: string;
}

export interface GithubDatasourceCache<StoredItem extends StoredItemBase> {
items: Record<string, StoredItem>;
cacheCreatedAt: string;
cacheUpdatedAt: string;
}

export interface GithubDatasourceCacheConfig<
FetchedItem extends FetchedItemBase,
StoredItem extends StoredItemBase
> {
type: string;
query: string;
coerceFetched: (x: FetchedItem) => StoredItem;
isEquivalent: (x: StoredItem, y: StoredItem) => boolean;
coerceStored: (x: StoredItem) => Release;
softResetMinutes?: number;
hardResetDays?: number;
stabilityPeriodDays?: number;
maxPages?: number;
}

function isExpired(
now: DateTime,
date: string,
duration: DurationLike
): boolean {
const then = DateTime.fromISO(date);
return now >= then.plus(duration);
}

export abstract class AbstractGithubDatasourceCache<
FetchedItem extends FetchedItemBase,
StoredItem extends StoredItemBase
> {
constructor(
private http: GithubHttp,
private softResetMinutes = 30,
private hardResetDays = 7,
private stabilityPeriodDays = 31,
private perPage = 100,
private maxPages = 10
) {}

abstract readonly cacheNs: string;
abstract readonly graphqlQuery: string;
abstract coerceFetched(fetchedItem: FetchedItem): StoredItem | null;
abstract isEquivalent(oldItem: StoredItem, newItem: StoredItem): boolean;
abstract coerceStored(storedItem: StoredItem): Release;

async getReleases(releasesConfig: GetReleasesConfig): Promise<Release[]> {
const { packageName, registryUrl } = releasesConfig;

const softReset: DurationLike = { minutes: this.softResetMinutes };
const hardReset: DurationLike = { days: this.hardResetDays };
const stabilityPeriod: DurationLike = { days: this.stabilityPeriodDays };

const now = DateTime.now();
let cache: GithubDatasourceCache<StoredItem> = {
items: {},
cacheCreatedAt: now.toISO(),
cacheUpdatedAt: now.minus(softReset).toISO(),
};

const baseUrl = getApiBaseUrl(registryUrl).replace('/v3/', '/');
rarkins marked this conversation as resolved.
Show resolved Hide resolved
const [owner, name] = packageName.split('/');
if (owner && name) {
const cacheKey = `${baseUrl}:${owner}/${name}`;
const cachedRes = await packageCache.get<
GithubDatasourceCache<StoredItem>
>(this.cacheNs, cacheKey);

let isCacheUpdated = false;

if (cachedRes && !isExpired(now, cachedRes.cacheCreatedAt, hardReset)) {
cache = cachedRes;
} else {
isCacheUpdated = true;
}

if (isExpired(now, cache.cacheUpdatedAt, softReset)) {
const variables: GithubQueryParams = {
owner,
name,
cursor: null,
count: this.perPage,
};

const checkedItems = new Set<string>();

try {
let pagesAllowed = this.maxPages;
let isIterating = true;
while (pagesAllowed > 0 && isIterating) {
const graphqlRes = await this.http.postJson<
GithubGraphqlResponse<QueryResponse<FetchedItem>>
>('/graphql', {
baseUrl,
body: { query: this.graphqlQuery, variables },
});
pagesAllowed -= 1;

const data = graphqlRes.body.data;
if (data) {
const {
nodes: fetchedItems,
pageInfo: { hasNextPage, endCursor },
} = data.repository.payload;

if (hasNextPage) {
variables.cursor = endCursor;
} else {
isIterating = false;
}

for (const item of fetchedItems) {
const newStoredItem = this.coerceFetched(item);
if (newStoredItem) {
const { version, releaseTimestamp } = newStoredItem;
checkedItems.add(version);

const oldStoredItem = cache.items[version];
if (
!oldStoredItem ||
!this.isEquivalent(oldStoredItem, newStoredItem)
) {
cache.items[version] = newStoredItem;
isCacheUpdated = true;
} else if (
isExpired(now, releaseTimestamp, stabilityPeriod)
) {
isIterating = false;
break;
}
}
}
}
}
} catch (err) {
logger.debug(
{ err },
`GitHub datasource: error fetching cacheable GraphQL data`
);
}

for (const [version, item] of Object.entries(cache.items)) {
if (
!isExpired(now, item.releaseTimestamp, stabilityPeriod) &&
!checkedItems.has(version)
) {
delete cache.items[version];
isCacheUpdated = true;
}
}

if (isCacheUpdated) {
const expiry = DateTime.fromISO(cache.cacheCreatedAt).plus(hardReset);
const { minutes: ttlMinutes } = expiry
.diff(now, ['minutes'])
.toObject();
if (ttlMinutes && ttlMinutes > 0) {
cache.cacheUpdatedAt = now.toISO();
await packageCache.set(this.cacheNs, cacheKey, cache, ttlMinutes);
}
}
}
}

const storedItems = Object.values(cache.items);
return storedItems.map((item) => this.coerceStored(item));
}
}
79 changes: 79 additions & 0 deletions lib/modules/datasource/github-releases/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { GithubHttp } from '../../../util/http/github';
import type { Release } from '../types';
import {
AbstractGithubDatasourceCache,
FetchedItemBase,
StoredItemBase,
} from './cache-base';

export const query = `
query ($owner: String!, $name: String!, $cursor: String, $count: Int!) {
repository(owner: $owner, name: $name) {
payload: releases(
after: $cursor
first: $count
orderBy: {field: CREATED_AT, direction: DESC}
) {
nodes {
version: tagName
gitRef: tagName
releaseTimestamp: publishedAt
isDraft
isPrerelease
updatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;

interface FetchedRelease extends FetchedItemBase {
isDraft: boolean;
isPrerelease: boolean;
updatedAt: string;
releaseTimestamp: string;
}

interface StoredRelease extends StoredItemBase {
isStable?: boolean;
updatedAt: string;
}

export class CacheableGithubReleases extends AbstractGithubDatasourceCache<
FetchedRelease,
StoredRelease
> {
cacheNs = 'github-datasource-graphql-releases';
graphqlQuery = query;

constructor(http: GithubHttp) {
super(http);
}

coerceFetched(item: FetchedRelease): StoredRelease {
const { version, releaseTimestamp, isDraft, isPrerelease, updatedAt } =
item;
const result: StoredRelease = { version, releaseTimestamp, updatedAt };
if (isPrerelease || isDraft) {
result.isStable = false;
}
return result;
}

coerceStored(item: StoredRelease): Release {
const { version, releaseTimestamp, isStable } = item;
const result: Release = { version, releaseTimestamp };
if (isStable !== undefined) {
result.isStable = isStable;
}
return result;
}

isEquivalent(oldItem: StoredRelease, newItem: StoredRelease): boolean {
return oldItem.releaseTimestamp === newItem.releaseTimestamp;
}
}
39 changes: 12 additions & 27 deletions lib/modules/datasource/github-releases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GithubHttp } from '../../../util/http/github';
import { newlineRegex, regEx } from '../../../util/regex';
import { Datasource } from '../datasource';
import type { DigestConfig, GetReleasesConfig, ReleaseResult } from '../types';
import { CacheableGithubReleases } from './cache';
import { getApiBaseUrl, getSourceUrl } from './common';
import type { DigestAsset, GithubRelease, GithubReleaseAsset } from './types';

Expand All @@ -27,9 +28,12 @@ export class GithubReleasesDatasource extends Datasource {

override http: GithubHttp;

private releasesCache: CacheableGithubReleases;

constructor(id = GithubReleasesDatasource.id) {
super(id);
this.http = new GithubHttp(id);
this.releasesCache = new CacheableGithubReleases(this.http);
}

async findDigestFile(
Expand Down Expand Up @@ -218,11 +222,6 @@ export class GithubReleasesDatasource extends Datasource {
return newDigest;
}

@cache({
namespace: 'datasource-github-releases',
key: ({ packageName: repo, registryUrl }: GetReleasesConfig) =>
`${registryUrl}:${repo}:tags`,
})
/**
* github.getReleases
*
Expand All @@ -233,27 +232,13 @@ export class GithubReleasesDatasource extends Datasource {
* - Sanitize the versions if desired (e.g. strip out leading 'v')
* - Return a dependency object containing sourceUrl string and releases array
*/
async getReleases({
packageName: repo,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const apiBaseUrl = getApiBaseUrl(registryUrl);
const url = `${apiBaseUrl}repos/${repo}/releases?per_page=100`;
const res = await this.http.getJson<GithubRelease[]>(url, {
paginate: true,
});
const githubReleases = res.body;
const dependency: ReleaseResult = {
sourceUrl: getSourceUrl(repo, registryUrl),
releases: githubReleases
.filter(({ draft }) => draft !== true)
.map(({ tag_name, published_at, prerelease }) => ({
version: tag_name,
gitRef: tag_name,
releaseTimestamp: published_at,
isStable: prerelease ? false : undefined,
})),
};
return dependency;
async getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
const releases = await this.releasesCache.getReleases(config);
return releases.length
? {
sourceUrl: getSourceUrl(config.packageName, config.registryUrl),
releases,
}
: null;
}
}