Files
renovate/lib/modules/datasource/go/timestamp-cache.spec.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

126 lines
3.9 KiB
TypeScript

import { GlobalConfig } from '../../../config/global.ts';
import * as _packageCache from '../../../util/cache/package/index.ts';
import type { Timestamp } from '../../../util/timestamp.ts';
import { GoVersionTimestampCache } from './timestamp-cache.ts';
vi.mock('../../../util/cache/package/index.ts');
const packageCache = vi.mocked(_packageCache);
describe('modules/datasource/go/timestamp-cache', () => {
const baseUrl = 'https://proxy.golang.org';
const packageName = 'github.com/foo/bar';
const cacheKey = 'https://proxy.golang.org@@github.com/foo/bar';
const ttlMinutes = 100 * 24 * 60;
const v1 = '2018-01-01T00:00:00.000Z' as Timestamp;
const v2 = '2019-01-01T00:00:00.000Z' as Timestamp;
afterEach(() => {
GlobalConfig.reset();
});
it('stores newly fetched timestamps', async () => {
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
expect(cache.get('v1.0.0')).toBeUndefined();
cache.set('v1.0.0', v1);
await cache.save();
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-go-proxy-timestamps',
cacheKey,
{ 'v1.0.0': v1 },
ttlMinutes,
);
});
it('serves known timestamps without writing again', async () => {
packageCache.get.mockResolvedValue({ 'v1.0.0': v1 });
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
expect(cache.get('v1.0.0')).toBe(v1);
await cache.save();
expect(packageCache.set).not.toHaveBeenCalled();
});
it('merges with timestamps written while it was fetching', async () => {
packageCache.get
.mockResolvedValueOnce({ 'v1.0.0': v1 })
.mockResolvedValueOnce({ 'v1.0.0': v1, 'v3.0.0': v2 });
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
cache.set('v2.0.0', v2);
await cache.save();
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-go-proxy-timestamps',
cacheKey,
{ 'v1.0.0': v1, 'v2.0.0': v2, 'v3.0.0': v2 },
ttlMinutes,
);
});
it('ignores cached data which is not a map of timestamps', async () => {
packageCache.get.mockResolvedValue({ 'v1.0.0': { not: 'a timestamp' } });
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
expect(cache.get('v1.0.0')).toBeUndefined();
});
it('keys on the proxy, without its credentials', async () => {
const cache = await GoVersionTimestampCache.init(
'https://user:pass@artifactory.example.com/api/go/go/',
packageName,
);
cache.set('v1.0.0', v1);
await cache.save();
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-go-proxy-timestamps',
'https://artifactory.example.com/api/go/go@@github.com/foo/bar',
{ 'v1.0.0': v1 },
ttlMinutes,
);
});
it('does not cache private modules', async () => {
vi.stubEnv('GOPRIVATE', 'github.com/foo/*');
packageCache.get.mockResolvedValue({ 'v1.0.0': v1 });
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
cache.set('v2.0.0', v2);
await cache.save();
expect(cache.get('v1.0.0')).toBeUndefined();
expect(packageCache.get).not.toHaveBeenCalled();
expect(packageCache.set).not.toHaveBeenCalled();
});
it('caches private modules if cachePrivatePackages is enabled', async () => {
GlobalConfig.set({ cachePrivatePackages: true });
vi.stubEnv('GOPRIVATE', 'github.com/foo/*');
const cache = await GoVersionTimestampCache.init(baseUrl, packageName);
cache.set('v1.0.0', v1);
await cache.save();
expect(packageCache.set).toHaveBeenCalledWith(
'datasource-go-proxy-timestamps',
cacheKey,
{ 'v1.0.0': v1 },
ttlMinutes,
);
});
it('does not cache if the proxy URL cannot be parsed', async () => {
const cache = await GoVersionTimestampCache.init('not-a-url', packageName);
cache.set('v1.0.0', v1);
await cache.save();
expect(packageCache.get).not.toHaveBeenCalled();
expect(packageCache.set).not.toHaveBeenCalled();
});
});