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: SQLite-based package cache #26608

Merged
merged 47 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4b18073
feat: SQLite-based package cache
zharinov Jan 12, 2024
a7fd248
Simplify implementation
zharinov Jan 12, 2024
8be0fb3
feat: Add `totalMs` for package cache stats
zharinov Jan 12, 2024
bc72b8b
Add composite index
zharinov Jan 12, 2024
34d6d90
Refactor
zharinov Jan 12, 2024
9201faf
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 12, 2024
30823fa
Update lib/util/cache/package/sqlite.ts
zharinov Jan 12, 2024
0ea5dd7
Use `unixepoch()`
zharinov Jan 12, 2024
7aeaa87
Options and coverage
zharinov Jan 12, 2024
f293655
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 12, 2024
f204773
Fix doc
zharinov Jan 12, 2024
9225e68
Cleanup log
zharinov Jan 12, 2024
8e41f0d
Fix types
zharinov Jan 12, 2024
8138a8f
Fix coverage
zharinov Jan 12, 2024
a58fa38
Move cleanup
zharinov Jan 13, 2024
5539de7
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 13, 2024
16457bc
Rename table
zharinov Jan 14, 2024
6537b0d
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 15, 2024
66d23e3
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 15, 2024
bfe44df
Revert lockfile
zharinov Jan 15, 2024
3952a7d
Update lockfile
zharinov Jan 15, 2024
3014171
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 15, 2024
247c32b
Add compressing
zharinov Jan 15, 2024
8a9b6d4
Async compression
zharinov Jan 15, 2024
f364782
Lower the compression level
zharinov Jan 16, 2024
1cb6077
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 16, 2024
b6db673
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 16, 2024
e101c79
Apply suggestions from code review
zharinov Jan 17, 2024
ac00892
Fix lockfile
zharinov Jan 17, 2024
d07311a
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 17, 2024
0ce9df1
Fix lockfile
zharinov Jan 17, 2024
c2742f0
Prettier fix
zharinov Jan 17, 2024
2e9f11f
Use env variable
zharinov Jan 17, 2024
5b0c7c0
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 17, 2024
64c78d8
Fix coverage
zharinov Jan 17, 2024
6bbc5d6
Refactor
zharinov Jan 17, 2024
55093bd
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 18, 2024
aecb10a
Add init log message
zharinov Jan 19, 2024
c2e19e0
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 19, 2024
d23ee3b
Fix tests and coverage
zharinov Jan 19, 2024
fae602a
Update docs/usage/self-hosted-experimental.md
zharinov Jan 19, 2024
f6203d9
Update lib/util/cache/package/index.spec.ts
zharinov Jan 19, 2024
fa6b58d
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 19, 2024
c0b64f0
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 23, 2024
a280e27
Simplify file existence check
zharinov Jan 23, 2024
8eaeaea
Merge branch 'main' into feat/sqlite-package-cache
zharinov Jan 29, 2024
926d59b
Fix
zharinov Jan 29, 2024
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
5 changes: 5 additions & 0 deletions docs/usage/self-hosted-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,8 @@ If set, Renovate will enable `forcePathStyle` when instantiating the AWS S3 clie
> Whether to force path style URLs for S3 objects (e.g., `https://s3.amazonaws.com//` instead of `https://.s3.amazonaws.com/`)

Source: [AWS S3 documentation - Interface BucketEndpointInputConfig](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/bucketendpointinputconfig.html)

## `RENOVATE_X_SQLITE_PACKAGE_CACHE`

If set, Renovate will use SQLite as the backend for the package cache.
zharinov marked this conversation as resolved.
Show resolved Hide resolved
Don't combine with `redisUrl`, Redis would be preferred over SQlite.
15 changes: 15 additions & 0 deletions lib/util/cache/package/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { cleanup, get, init, set } from '.';

jest.mock('./file');
jest.mock('./redis');
jest.mock('./sqlite');

describe('util/cache/package/index', () => {
beforeEach(() => {
delete process.env.RENOVATE_X_SQLITE_PACKAGE_CACHE;
});

it('returns undefined if not initialized', async () => {
expect(await get('test', 'missing-key')).toBeUndefined();
expect(await set('test', 'some-key', 'some-value', 5)).toBeUndefined();
Expand All @@ -28,4 +33,14 @@ describe('util/cache/package/index', () => {
expect(await get('some-namespace', 'unknown-key')).toBeUndefined();
expect(await cleanup({ redisUrl: 'some-url' })).toBeUndefined();
});

it('sets and gets sqlite', async () => {
process.env.RENOVATE_X_SQLITE_PACKAGE_CACHE = 'true';
await init({ cacheDir: 'some-dir' });
expect(
await set('some-namespace', 'some-key', 'some-value', 1),
).toBeUndefined();
expect(await get('some-namespace', 'unknown-key')).toBeUndefined();
expect(await cleanup({ redisUrl: 'some-url' })).toBeUndefined();
});
});
12 changes: 11 additions & 1 deletion lib/util/cache/package/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AllConfig } from '../../../config/types';
import * as memCache from '../memory';
import * as fileCache from './file';
import * as redisCache from './redis';
import { SqlitePackageCache } from './sqlite';
import type { PackageCache } from './types';

let cacheProxy: PackageCache | undefined;
Expand Down Expand Up @@ -60,13 +61,22 @@ export async function init(config: AllConfig): Promise<void> {
get: redisCache.get,
set: redisCache.set,
};
} else if (config.cacheDir) {
return;
}

if (process.env.RENOVATE_X_SQLITE_PACKAGE_CACHE) {
cacheProxy = await SqlitePackageCache.init(config.cacheDir!);
return;
}

if (config.cacheDir) {
fileCache.init(config.cacheDir);
cacheProxy = {
get: fileCache.get,
set: fileCache.set,
cleanup: fileCache.cleanup,
};
return;
}
}

Expand Down
55 changes: 55 additions & 0 deletions lib/util/cache/package/sqlite.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { withDir } from 'tmp-promise';
import { GlobalConfig } from '../../../config/global';
import { SqlitePackageCache } from './sqlite';

function withSqlite<T>(
fn: (sqlite: SqlitePackageCache) => Promise<T>,
): Promise<T> {
return withDir(
async ({ path }) => {
GlobalConfig.set({ cacheDir: path });
const sqlite = await SqlitePackageCache.init(path);
const res = await fn(sqlite);
await sqlite.cleanup();
return res;
},
{ unsafeCleanup: true },
);
}

describe('util/cache/package/sqlite', () => {
it('should get undefined', async () => {
const res = await withSqlite((sqlite) => sqlite.get('foo', 'bar'));
expect(res).toBeUndefined();
});

it('should set and get', async () => {
const res = await withSqlite(async (sqlite) => {
await sqlite.set('foo', 'bar', { foo: 'foo' });
await sqlite.set('foo', 'bar', { bar: 'bar' });
await sqlite.set('foo', 'bar', { baz: 'baz' });
return sqlite.get('foo', 'bar');
});
expect(res).toEqual({ baz: 'baz' });
});

it('reopens', async () => {
const res = await withDir(
async ({ path }) => {
GlobalConfig.set({ cacheDir: path });

const client1 = await SqlitePackageCache.init(path);
await client1.set('foo', 'bar', 'baz');
await client1.cleanup();

const client2 = await SqlitePackageCache.init(path);
const res = await client2.get('foo', 'bar');
await client2.cleanup();
return res;
},
{ unsafeCleanup: true },
);

expect(res).toBe('baz');
});
});
148 changes: 148 additions & 0 deletions lib/util/cache/package/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { promisify } from 'node:util';
import zlib, { constants } from 'node:zlib';
import Sqlite from 'better-sqlite3';
import type { Database, Statement } from 'better-sqlite3';
import { exists } from 'fs-extra';
import * as upath from 'upath';
import { logger } from '../../../logger';
import { ensureDir } from '../../fs';

const brotliCompress = promisify(zlib.brotliCompress);
const brotliDecompress = promisify(zlib.brotliDecompress);

function compress(input: unknown): Promise<Buffer> {
const jsonStr = JSON.stringify(input);
return brotliCompress(jsonStr, {
params: {
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
[constants.BROTLI_PARAM_QUALITY]: 3,
},
});
}

async function decompress<T>(input: Buffer): Promise<T> {
const buf = await brotliDecompress(input);
const jsonStr = buf.toString('utf8');
return JSON.parse(jsonStr) as T;
}
zharinov marked this conversation as resolved.
Show resolved Hide resolved

export class SqlitePackageCache {
private readonly upsertStatement: Statement<unknown[]>;
private readonly getStatement: Statement<unknown[]>;
private readonly deleteExpiredRows: Statement<unknown[]>;
private readonly countStatement: Statement<unknown[]>;

static async init(cacheDir: string): Promise<SqlitePackageCache> {
const sqliteDir = upath.join(cacheDir, 'renovate/renovate-cache-sqlite');
await ensureDir(sqliteDir);
const sqliteFile = upath.join(sqliteDir, 'db.sqlite');

if (await exists(sqliteFile)) {
logger.debug(`Using SQLite package cache: ${sqliteFile}`);
} else {
logger.debug(`Creating SQLite package cache: ${sqliteFile}`);
}

const client = new Sqlite(sqliteFile);
const res = new SqlitePackageCache(client);
viceice marked this conversation as resolved.
Show resolved Hide resolved
return res;
}

private constructor(private client: Database) {
client.pragma('journal_mode = WAL');
client.pragma("encoding = 'UTF-8'");

client
.prepare(
`
CREATE TABLE IF NOT EXISTS package_cache (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
expiry INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (namespace, key)
)
`,
)
.run();
client
.prepare('CREATE INDEX IF NOT EXISTS expiry ON package_cache (expiry)')
.run();
client
.prepare(
'CREATE INDEX IF NOT EXISTS namespace_key ON package_cache (namespace, key)',
)
.run();

this.upsertStatement = client.prepare(`
INSERT INTO package_cache (namespace, key, data, expiry)
VALUES (@namespace, @key, @data, unixepoch() + @ttlSeconds)
ON CONFLICT (namespace, key) DO UPDATE SET
data = @data,
expiry = unixepoch() + @ttlSeconds
`);

this.getStatement = client
.prepare(
`
SELECT data FROM package_cache
WHERE
namespace = @namespace AND key = @key AND expiry > unixepoch()
`,
)
.pluck(true);

this.deleteExpiredRows = client.prepare(`
DELETE FROM package_cache
WHERE expiry <= unixepoch()
`);

this.countStatement = client
.prepare('SELECT COUNT(*) FROM package_cache')
.pluck(true);
}

async set(
namespace: string,
key: string,
value: unknown,
ttlMinutes = 5,
): Promise<void> {
const data = await compress(value);
const ttlSeconds = ttlMinutes * 60;
this.upsertStatement.run({ namespace, key, data, ttlSeconds });
return Promise.resolve();
}

async get<T = unknown>(
namespace: string,
key: string,
): Promise<T | undefined> {
const data = this.getStatement.get({ namespace, key }) as
| Buffer
| undefined;

if (!data) {
return undefined;
}

return await decompress<T>(data);
}

private cleanupExpired(): void {
const start = Date.now();
const totalCount = this.countStatement.get() as number;
const { changes: deletedCount } = this.deleteExpiredRows.run();
const finish = Date.now();
const durationMs = finish - start;
logger.debug(
`SQLite package cache: deleted ${deletedCount} of ${totalCount} entries in ${durationMs}ms`,
);
}

cleanup(): Promise<void> {
this.cleanupExpired();
this.client.close();
return Promise.resolve();
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
"@renovatebot/pep440": "3.0.17",
"@renovatebot/ruby-semver": "3.0.23",
"@sindresorhus/is": "4.6.0",
"@types/better-sqlite3": "7.6.8",
"@types/ms": "0.7.34",
"@types/tmp": "0.2.6",
"@yarnpkg/core": "4.0.2",
Expand All @@ -177,6 +178,7 @@
"auth-header": "1.0.0",
"aws4": "1.12.0",
"azure-devops-node-api": "12.3.0",
"better-sqlite3": "9.2.2",
"bunyan": "1.8.15",
"cacache": "18.0.2",
"cacheable-lookup": "5.0.4",
Expand Down
Loading
Loading