mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 15:01:49 +00:00
362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
import { promisify } from 'node:util';
|
|
import zlib from 'node:zlib';
|
|
import { DateTime } from 'luxon';
|
|
import { withDir } from 'tmp-promise';
|
|
import { logger as _logger } from '~test/util.ts';
|
|
import { GlobalConfig } from '../../../../config/global.ts';
|
|
import { decodeEntry, encodeEntry, isEnvelope } from '../codec.ts';
|
|
import { PackageCacheSqlite } from './sqlite.ts';
|
|
|
|
const { logger } = _logger;
|
|
|
|
// TODO: Delete this legacy raw-brotli fixture helper once legacy.ts is removed.
|
|
const brotliCompress = promisify(zlib.brotliCompress);
|
|
|
|
function withSqliteDir<T>(fn: (cacheDir: string) => Promise<T>): Promise<T> {
|
|
return withDir(
|
|
async ({ path }) => {
|
|
GlobalConfig.set({ cacheDir: path });
|
|
return await fn(path);
|
|
},
|
|
{ unsafeCleanup: true },
|
|
);
|
|
}
|
|
|
|
function withSqlite<T>(
|
|
fn: (sqlite: PackageCacheSqlite) => Promise<T>,
|
|
): Promise<T> {
|
|
return withSqliteDir(async (cacheDir) => {
|
|
const sqlite = await PackageCacheSqlite.create(cacheDir);
|
|
|
|
try {
|
|
return await fn(sqlite);
|
|
} finally {
|
|
await sqlite.destroy();
|
|
}
|
|
});
|
|
}
|
|
|
|
function insertRawCacheEntry(
|
|
sqlite: PackageCacheSqlite,
|
|
key: string,
|
|
value: Buffer,
|
|
ttlSeconds = 300,
|
|
): void {
|
|
sqlite.client
|
|
.prepare(
|
|
`
|
|
INSERT INTO package_cache (namespace, key, expiry, data)
|
|
VALUES (?, ?, unixepoch() + ?, ?)
|
|
`,
|
|
)
|
|
.run('_test-namespace', key, ttlSeconds, value);
|
|
}
|
|
|
|
describe('util/cache/package/impl/sqlite', () => {
|
|
describe('get', () => {
|
|
it('returns undefined on cache miss', async () => {
|
|
const res = await withSqlite((sqlite) =>
|
|
sqlite.get('_test-namespace', 'bar'),
|
|
);
|
|
|
|
expect(res).toBeUndefined();
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// TODO: Replace this legacy raw-brotli malformed-entry case with an
|
|
// unsupported non-envelope fixture once legacy.ts is removed.
|
|
it('removes invalid compressed payloads', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
insertRawCacheEntry(sqlite, 'bar', Buffer.from('not-brotli'));
|
|
|
|
const value = await sqlite.get('_test-namespace', 'bar');
|
|
const row = sqlite.client
|
|
.prepare(
|
|
'SELECT data FROM package_cache WHERE namespace = ? AND key = ?',
|
|
)
|
|
.get('_test-namespace', 'bar');
|
|
|
|
return { value, row };
|
|
});
|
|
|
|
expect(res.value).toBeUndefined();
|
|
expect(res.row).toBeUndefined();
|
|
expect(logger.once.debug).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while reading package cache value',
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// TODO: Delete this legacy raw-brotli malformed-entry case once legacy.ts is
|
|
// removed.
|
|
it('returns undefined for invalid JSON payload', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
const compressed = await brotliCompress('not-json');
|
|
insertRawCacheEntry(sqlite, 'bar', compressed);
|
|
|
|
return sqlite.get('_test-namespace', 'bar');
|
|
});
|
|
|
|
expect(res).toBeUndefined();
|
|
expect(logger.once.debug).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while reading package cache value',
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns undefined when the read fails', async () => {
|
|
await withSqliteDir(async (cacheDir) => {
|
|
const sqlite = await PackageCacheSqlite.create(cacheDir);
|
|
|
|
try {
|
|
sqlite.client.exec('DROP TABLE package_cache');
|
|
|
|
const res = await sqlite.get('_test-namespace', 'bar');
|
|
|
|
expect(res).toBeUndefined();
|
|
expect(logger.once.debug).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while reading package cache value',
|
|
);
|
|
expect(logger.trace).not.toHaveBeenCalledWith(
|
|
{ namespace: '_test-namespace', key: 'bar' },
|
|
'Cache miss',
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
sqlite.client.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('set', () => {
|
|
it('stores envelope payload with backend-native expiry', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
await sqlite.set('_test-namespace', 'bar', { foo: 'bar' }, 5);
|
|
const row = sqlite.client
|
|
.prepare(
|
|
`
|
|
SELECT data, expiry > unixepoch() AS hasFutureExpiry
|
|
FROM package_cache
|
|
WHERE namespace = ? AND key = ?
|
|
`,
|
|
)
|
|
.get('_test-namespace', 'bar') as {
|
|
data: Uint8Array;
|
|
hasFutureExpiry: number;
|
|
};
|
|
|
|
return {
|
|
decoded: await decodeEntry(Buffer.from(row.data)),
|
|
hasFutureExpiry: row.hasFutureExpiry,
|
|
};
|
|
});
|
|
|
|
expect(res.decoded.value).toEqual({ foo: 'bar' });
|
|
expect(res.hasFutureExpiry).toBe(1);
|
|
});
|
|
|
|
it('logs a warning and continues when serialization fails', async () => {
|
|
const circular: { self?: unknown } = {};
|
|
circular.self = circular;
|
|
|
|
await expect(
|
|
withSqlite((sqlite) =>
|
|
sqlite.set('_test-namespace', 'bar', circular, 5),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(logger.once.warn).toHaveBeenCalledWith(
|
|
{ err: expect.any(TypeError) },
|
|
'Error while setting package cache value',
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs a warning and continues when the write fails', async () => {
|
|
await withSqliteDir(async (cacheDir) => {
|
|
const sqlite = await PackageCacheSqlite.create(cacheDir);
|
|
|
|
try {
|
|
sqlite.client.exec('DROP TABLE package_cache');
|
|
|
|
await expect(
|
|
sqlite.set('_test-namespace', 'bar', { foo: 'bar' }, 5),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(logger.once.warn).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while setting package cache value',
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
sqlite.client.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('set and get', () => {
|
|
it('overwrites and returns latest value', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
await sqlite.set('_test-namespace', 'bar', { foo: 'foo' }, 5);
|
|
await sqlite.set('_test-namespace', 'bar', { bar: 'bar' }, 5);
|
|
await sqlite.set('_test-namespace', 'bar', { baz: 'baz' }, 5);
|
|
return sqlite.get('_test-namespace', 'bar');
|
|
});
|
|
|
|
expect(res).toEqual({ baz: 'baz' });
|
|
});
|
|
|
|
it('returns value from envelope payload', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
insertRawCacheEntry(
|
|
sqlite,
|
|
'bar',
|
|
await encodeEntry({ foo: 'bar' }, DateTime.local()),
|
|
);
|
|
|
|
return sqlite.get('_test-namespace', 'bar');
|
|
});
|
|
|
|
expect(res).toEqual({ foo: 'bar' });
|
|
});
|
|
|
|
// TODO: Delete this legacy raw-brotli read case once legacy.ts is removed.
|
|
it('returns value from legacy raw-brotli payload without rewriting it', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
const compressed = await brotliCompress(JSON.stringify({ foo: 'bar' }));
|
|
insertRawCacheEntry(sqlite, 'bar', compressed);
|
|
|
|
const value = await sqlite.get('_test-namespace', 'bar');
|
|
const row = sqlite.client
|
|
.prepare(
|
|
'SELECT data FROM package_cache WHERE namespace = ? AND key = ?',
|
|
)
|
|
.get('_test-namespace', 'bar') as { data: Uint8Array };
|
|
|
|
return { value, data: Buffer.from(row.data) };
|
|
});
|
|
|
|
expect(res.value).toEqual({ foo: 'bar' });
|
|
// SQLite cleanup uses its expiry column, so it must not rewrite on read.
|
|
expect(isEnvelope(res.data)).toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('expiry', () => {
|
|
it('deletes existing row for non-positive TTL', async () => {
|
|
const res = await withSqlite(async (sqlite) => {
|
|
await sqlite.set('_test-namespace', 'bar', 'value', 5);
|
|
await sqlite.set('_test-namespace', 'bar', 'value', -1);
|
|
const row = sqlite.client
|
|
.prepare(
|
|
'SELECT data FROM package_cache WHERE namespace = ? AND key = ?',
|
|
)
|
|
.get('_test-namespace', 'bar');
|
|
|
|
return {
|
|
value: await sqlite.get('_test-namespace', 'bar'),
|
|
row,
|
|
};
|
|
});
|
|
|
|
expect(res.value).toBeUndefined();
|
|
expect(res.row).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('destroy', () => {
|
|
it('deletes expired rows and closes database', async () => {
|
|
const res = await withSqliteDir(async (cacheDir) => {
|
|
const client1 = await PackageCacheSqlite.create(cacheDir);
|
|
insertRawCacheEntry(
|
|
client1,
|
|
'expired',
|
|
await encodeEntry('old', DateTime.local()),
|
|
-1,
|
|
);
|
|
await client1.set('_test-namespace', 'valid', 'fresh', 5);
|
|
await client1.destroy();
|
|
|
|
const client2 = await PackageCacheSqlite.create(cacheDir);
|
|
const expired = await client2.get('_test-namespace', 'expired');
|
|
const valid = await client2.get('_test-namespace', 'valid');
|
|
const expiredRow = client2.client
|
|
.prepare(
|
|
'SELECT data FROM package_cache WHERE namespace = ? AND key = ?',
|
|
)
|
|
.get('_test-namespace', 'expired');
|
|
await client2.destroy();
|
|
|
|
return { expired, expiredRow, valid };
|
|
});
|
|
|
|
expect(res.expired).toBeUndefined();
|
|
expect(res.expiredRow).toBeUndefined();
|
|
expect(res.valid).toBe('fresh');
|
|
});
|
|
|
|
it('resolves and still closes when cleanup throws', async () => {
|
|
await withSqliteDir(async (cacheDir) => {
|
|
const sqlite = await PackageCacheSqlite.create(cacheDir);
|
|
sqlite.client.exec('DROP TABLE package_cache');
|
|
|
|
await expect(sqlite.destroy()).resolves.toBeUndefined();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'SQLite package cache cleanup failed',
|
|
);
|
|
expect(() => sqlite.client.prepare('SELECT 1').get()).toThrow(
|
|
'database is not open',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('resolves when close throws', async () => {
|
|
await withSqliteDir(async (cacheDir) => {
|
|
const sqlite = await PackageCacheSqlite.create(cacheDir);
|
|
const closeSpy = vi
|
|
.spyOn(sqlite.client, 'close')
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('close failed');
|
|
});
|
|
|
|
try {
|
|
await expect(sqlite.destroy()).resolves.toBeUndefined();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'SQLite package cache close failed',
|
|
);
|
|
} finally {
|
|
expect(sqlite.client.isOpen).toBe(true);
|
|
closeSpy.mockRestore();
|
|
sqlite.client.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('persistence', () => {
|
|
it('retrieves value from persistent storage after reopening', async () => {
|
|
const res = await withSqliteDir(async (cacheDir) => {
|
|
const client1 = await PackageCacheSqlite.create(cacheDir);
|
|
await client1.set('_test-namespace', 'bar', 'baz', 5);
|
|
await client1.destroy();
|
|
|
|
const client2 = await PackageCacheSqlite.create(cacheDir);
|
|
const data = await client2.get('_test-namespace', 'bar');
|
|
await client2.destroy();
|
|
return data;
|
|
});
|
|
|
|
expect(res).toBe('baz');
|
|
});
|
|
});
|
|
});
|