Files
renovate/lib/modules/datasource/go/timestamp-cache.ts
Jamie Tanna aae889a285 fix(datasource/go): cache the publication time of each version (#45951)
* fix(datasource/go): cache the publication time of each version

The Go proxy defines that the `.info` API call is for a specific release
version of a module, this file should never be modified after it's been
created.

However, because we previously had a 30m TTL on these entries, a
repository that runs infrequently can lead to re-fetching data that
doesn't ever update.

We can fix this by introducing a separate cache namespace for the
module's creation timestamp (`Time` in the `.info` API call).

We can make sure that:

- we always merge, not replace, the entries, as the `major < 2` filter
  in `_getReleases` means a run may only see a subset of a module's
  versions, and writing back only what we saw would evict the rest
- we re-read immediately before writing, as `foo` and `foo/v2` are
  separate lookups which both walk the `v2` module, so one can overwrite
  the other's timestamps
- the key is the proxy which served the data, minus any credentials,
  since a private Artifactory `GOPROXY` is a different universe of
  timestamps to `proxy.golang.org`
- only the timestamps we successfully fetched are stored, so a proxy
  which serves no `Time` doesn't have that recorded for 100 days
- it uses its own namespace, so that a `cacheTtlOverride` on the release
  cache doesn't drag the immutable data down with it

Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>

* fixup! fix(datasource/go): cache the publication time of each version

Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>

* fixup! fixup! fix(datasource/go): cache the publication time of each version

Co-Authored-By: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>

---------

Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
2026-09-15 16:02:16 +00:00

99 lines
3.5 KiB
TypeScript

import { GlobalConfig } from '../../../config/global.ts';
import * as packageCache from '../../../util/cache/package/index.ts';
import { acquireLock } from '../../../util/mutex.ts';
import type { Timestamp } from '../../../util/timestamp.ts';
import { parseUrl, trimTrailingSlash } from '../../../util/url.ts';
import { isPublicGoPackage } from './common.ts';
import { VersionTimestamps } from './schema.ts';
const cacheNamespace = 'datasource-go-proxy-timestamps';
// a module's `go.mod` should /never/ change after it's published. If going via the Go Proxy and the Go Checksum Database, a change in this value will result in build failures.
const ttlMinutes = 100 * 24 * 60;
/**
* The publication times we already know for a module, so that a run only fetches the `.info` file of a version it has never seen before.
*
* This is intentionally separate from the Go module's releases cache, as the current releases list changes (somewhat frequently), whereas the release timestamp for a given Go module shouldn't change after publication.
*/
export class GoVersionTimestampCache {
private isChanged = false;
private cacheKey: string | null;
private timestamps: Record<string, Timestamp>;
private constructor(
cacheKey: string | null,
timestamps: Record<string, Timestamp>,
) {
this.cacheKey = cacheKey;
this.timestamps = timestamps;
}
static async init(
baseUrl: string,
packageName: string,
): Promise<GoVersionTimestampCache> {
const cacheKey = getCacheKey(baseUrl, packageName);
if (!cacheKey) {
return new GoVersionTimestampCache(null, {});
}
return new GoVersionTimestampCache(cacheKey, await read(cacheKey));
}
get(version: string): Timestamp | undefined {
return this.timestamps[version];
}
set(version: string, timestamp: Timestamp): void {
this.timestamps[version] = timestamp;
this.isChanged = true;
}
async save(): Promise<void> {
if (!this.cacheKey || !this.isChanged) {
return;
}
// `foo` and `foo/v2` are separate lookups which both walk the `v2` module, so another one of them may have written its own timestamps while we were fetching ours. The lock only protects against concurrent writes within this process, not across separate Renovate processes/machines sharing the same cache backend.
const releaseLock = await acquireLock(this.cacheKey, cacheNamespace);
try {
const current = await read(this.cacheKey);
await packageCache.set(
cacheNamespace,
this.cacheKey,
{ ...current, ...this.timestamps },
ttlMinutes,
);
} finally {
releaseLock();
}
}
}
async function read(cacheKey: string): Promise<Record<string, Timestamp>> {
const cached = await packageCache.get<unknown>(cacheNamespace, cacheKey);
return cached ? VersionTimestamps.parse(cached) : {};
}
/**
* Timestamps are only shared between modules served by the same proxy, and only stored at all for modules we may cache.
*
* Any credentials in the proxy URL are left out, as the key is stored as-is by the cache backends.
*/
function getCacheKey(baseUrl: string, packageName: string): string | null {
const cachePrivatePackages = GlobalConfig.get('cachePrivatePackages');
if (!cachePrivatePackages && !isPublicGoPackage(packageName)) {
return null;
}
const parsedUrl = parseUrl(baseUrl);
if (!parsedUrl) {
return null;
}
const proxy = `${parsedUrl.origin}${trimTrailingSlash(parsedUrl.pathname)}`;
return `${proxy}@@${packageName}`;
}