Path: blob/main/extensions/copilot/test/base/cache.ts
13388 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import KeyvSqlite from '@keyv/sqlite';6import { exec } from 'child_process';7import fs from 'fs';8import Keyv from 'keyv';9import { EventEmitter } from 'node:stream';10import path from 'path';11import { promisify } from 'util';12import zlib from 'zlib';13import { LockMap } from '../../src/util/common/lock';14import { generateUuid } from '../../src/util/vs/base/common/uuid';15import { CurrentTestRunInfo } from './simulationContext';1617const compress = promisify(zlib.brotliCompress);18const decompress = promisify(zlib.brotliDecompress);1920const DefaultCachePath = process.env.VITEST ? path.resolve(__dirname, '..', 'simulation', 'cache') : path.resolve(__dirname, '..', 'test', 'simulation', 'cache');2122async function getGitRoot(cwd: string): Promise<string> {23const execAsync = promisify(exec);24const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd });25return stdout.trim();26}2728export class Cache extends EventEmitter {29private static _Instance: Cache | undefined;30static get Instance() {31return this._Instance ?? (this._Instance = new Cache());32}3334private readonly cachePath: string;35private readonly layersPath: string;36private readonly externalLayersPath?: string;3738private readonly base: Keyv;39private readonly layers: Map<string, Keyv>;40private activeLayer: Promise<Keyv> | undefined;4142private gcBase: Keyv | undefined;43private gcBaseKeys: Set<string> | undefined;4445constructor(cachePath = DefaultCachePath) {46super();4748this.cachePath = cachePath;49this.layersPath = path.join(this.cachePath, 'layers');50this.externalLayersPath = process.env.EXTERNAL_CACHE_LAYERS_PATH;5152if (!fs.existsSync(path.join(this.cachePath, 'base.sqlite'))) {53throw new Error(`Base cache file does not exist as ${path.join(this.cachePath, 'base.sqlite')}.`);54}5556if (this.externalLayersPath && !fs.existsSync(this.externalLayersPath)) {57throw new Error(`External layers cache directory provided but it does not exist at ${this.externalLayersPath}.`);58}5960fs.mkdirSync(this.layersPath, { recursive: true });61this.base = new Keyv(new KeyvSqlite(path.join(this.cachePath, 'base.sqlite')));6263this.layers = new Map();64let layerFiles = fs.readdirSync(this.layersPath)65.filter(file => file.endsWith('.sqlite'))66.map(file => path.join(this.layersPath, file));6768if (this.externalLayersPath !== undefined) {69const externalLayerFiles = fs.readdirSync(this.externalLayersPath)70.filter(file => file.endsWith('.sqlite'))71.map(file => path.join(this.externalLayersPath!, file));72layerFiles = layerFiles.concat(externalLayerFiles);73}7475for (const layerFile of layerFiles) {76const name = path.basename(layerFile, path.extname(layerFile));77this.layers.set(name, new Keyv(new KeyvSqlite(layerFile)));78}79}8081async get(key: string): Promise<string | undefined> {82let data: string | undefined;8384// First check base database85data = await this.base.get(key) as string;8687if (!data) {88// Check layer databases89for (const [, layer] of this.layers) {90data = await layer.get(key) as string;9192if (data) {93break;94}95}96}9798if (!data) {99return undefined;100}101102// GC mode in progress103if (this.gcBase && this.gcBaseKeys) {104if (!this.gcBaseKeys.has(key)) {105if (await this.gcBase.set(key, data)) {106this.gcBaseKeys.add(key);107}108}109}110111return this._decompress(data);112}113114async set(key: string, value: string, layer?: 'base' | string): Promise<void> {115if (await this.has(key)) {116throw new Error(`Key already exists in cache: ${key}`);117}118119const data = await this._compress(value);120121switch (layer) {122case undefined: {123const layerDatabase = await this._getActiveLayerDatabase();124await layerDatabase.set(key, data);125break;126}127case 'base': {128await this.base.set(key, data);129break;130}131default: {132const layerDatabase = this.layers.get(layer);133if (!layerDatabase) {134throw new Error(`Layer with UUID not found: ${layer}`);135}136await layerDatabase.set(key, data);137break;138}139}140141}142143async has(key: string): Promise<boolean> {144// Check primary first145if (await this.base.has(key)) {146return true;147}148149// Check layers150for (const layer of this.layers.values()) {151if (await layer.has(key)) {152return true;153}154}155return false;156}157158async checkDatabase(): Promise<Map<string, string[]>> {159const keys = new Map<string, string>();160const result = new Map<string, string[]>();161162const checkDatabase = async (name: string, database: Keyv) => {163for await (const [key] of database.store.iterator()) {164if (result.has(key)) {165result.get(key)!.push(name);166} else if (keys.has(key)) {167result.set(key, [keys.get(key)!, name]);168keys.delete(key);169} else {170keys.set(key, name);171}172}173};174175// Base database176await checkDatabase('base', this.base);177178// Layer databases179for (const [uuid, database] of this.layers.entries()) {180await checkDatabase(uuid, database);181}182183return result;184}185186async gcStart(): Promise<void> {187if (this.gcBase || this.gcBaseKeys) {188throw new Error('GC is currently in progress');189}190191this.gcBaseKeys = new Set<string>();192this.gcBase = new Keyv(new KeyvSqlite(path.join(this.cachePath, '_base.sqlite')));193}194195async gcEnd(): Promise<void> {196if (!this.gcBase || !this.gcBaseKeys) {197throw new Error('GC is not in progress');198}199200// Close the connections201await this.base.disconnect();202await this.gcBase.disconnect();203204// Delete base.sqlite205fs.unlinkSync(path.join(this.cachePath, 'base.sqlite'));206207// Rename _base.sqlite to base.sqlite208fs.renameSync(209path.join(this.cachePath, '_base.sqlite'),210path.join(this.cachePath, 'base.sqlite'));211212// Delete the layer databases213for (const [uuid, layer] of this.layers.entries()) {214try {215// Close the connection216await layer.disconnect();217} catch (error) { }218219try {220// Delete the layer database221fs.unlinkSync(path.join(this.layersPath, `${uuid}.sqlite`));222} catch (error) { }223}224225this.activeLayer = undefined;226this.layers.clear();227228this.gcBase = undefined;229this.gcBaseKeys.clear();230this.gcBaseKeys = undefined;231}232233private async _getActiveLayerDatabase(): Promise<Keyv> {234if (!this.activeLayer) {235this.activeLayer = (async () => {236const execAsync = promisify(exec);237238const activeLayerPath = this.externalLayersPath ?? this.layersPath;239const gitStatusPath = this.externalLayersPath240? `${path.relative(await getGitRoot(activeLayerPath), activeLayerPath)}/*`241: 'test/simulation/cache/layers/*';242243// Check git for an uncommitted layer database file244try {245const gitRoot = await getGitRoot(activeLayerPath);246const { stdout: statusStdout } = await execAsync(`git status -z ${gitStatusPath}`, { cwd: gitRoot });247if (statusStdout !== '') {248const layerDatabaseEntries = statusStdout.split('\0').filter(entry => entry.endsWith('.sqlite'));249if (layerDatabaseEntries.length > 0) {250const regex = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.sqlite$/;251const match = layerDatabaseEntries[0].match(regex);252if (match && this.layers.has(match[1])) {253return this.layers.get(match[1])!;254}255}256}257} catch (error) {258// If git operations fail, continue to create new layer259}260261// Create a new layer database262const uuid = generateUuid();263const activeLayer = new Keyv(new KeyvSqlite(path.join(activeLayerPath, `${uuid}.sqlite`)));264this.layers.set(uuid, activeLayer);265return activeLayer;266})();267}268269return this.activeLayer;270}271272private async _compress(value: string): Promise<string> {273const buffer = await compress(value, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 6, } });274return buffer.toString('base64');275}276277private async _decompress(data: string): Promise<string> {278const buffer = await decompress(Buffer.from(data, 'base64'));279return buffer.toString('utf8');280}281}282283export type CacheableRequest = {284readonly hash: string;285toJSON?(): unknown;286};287288export interface ICache<TRequest, TResponse> {289get(req: TRequest): Promise<TResponse | undefined>;290set(req: TRequest, cachedResponse: TResponse): Promise<void>;291}292293export class SQLiteCache<TRequest extends CacheableRequest, TResponse> implements ICache<TRequest, TResponse> {294295private readonly namespace: string;296private readonly locks = new LockMap();297298constructor(name: string, salt?: string, info?: CurrentTestRunInfo) {299this.namespace = `${name}${salt ? `|${salt}` : ''}`;300}301302async hasRequest(hash: string): Promise<boolean> {303return Cache.Instance.has(`${this.namespace}:request:${hash}`);304}305306async getRequest(hash: string): Promise<TRequest | undefined> {307const result = await Cache.Instance.get(`${this.namespace}:request:${hash}`);308return result ? JSON.parse(result) : undefined;309}310311async setRequest(hash: string, value: TRequest): Promise<void> {312await Cache.Instance.set(`${this.namespace}:request:${hash}`, JSON.stringify(value));313}314315async has(req: TRequest): Promise<boolean> {316return Cache.Instance.has(`${this.namespace}:response:${req.hash}`);317}318319async get(req: TRequest): Promise<TResponse | undefined> {320const result = await Cache.Instance.get(`${this.namespace}:response:${req.hash}`);321return result ? JSON.parse(result) : undefined;322}323324async set(req: TRequest, value: TResponse): Promise<void> {325await this.locks.withLock(req.hash, async () => {326if (!!req.toJSON && !await this.hasRequest(req.hash)) {327await this.setRequest(req.hash, req);328}329});330331await Cache.Instance.set(`${this.namespace}:response:${req.hash}`, JSON.stringify(value));332}333}334335export interface ISlottedCache<TRequest, TResponse> {336get(req: TRequest, cacheSlot: number): Promise<TResponse | undefined>;337set(req: TRequest, cacheSlot: number, cachedResponse: TResponse): Promise<void>;338}339340export class SQLiteSlottedCache<TRequest extends CacheableRequest, TResponse> implements ISlottedCache<TRequest, TResponse> {341342private readonly namespace: string;343private readonly locks = new LockMap();344345constructor(name: string, salt: string, info?: CurrentTestRunInfo) {346this.namespace = `${name}|${salt}`;347}348349async hasRequest(hash: string): Promise<boolean> {350return Cache.Instance.has(`${this.namespace}:request:${hash}`);351}352353async getRequest(hash: string): Promise<TRequest | undefined> {354const result = await Cache.Instance.get(`${this.namespace}:request:${hash}`);355return result ? JSON.parse(result) : undefined;356}357358async setRequest(hash: string, value: TRequest): Promise<void> {359await Cache.Instance.set(`${this.namespace}:request:${hash}`, JSON.stringify(value));360}361362async has(req: TRequest, cacheSlot: number): Promise<boolean> {363return Cache.Instance.has(`${this.namespace}:response:${req.hash}:${cacheSlot}`);364}365366async get(req: TRequest, cacheSlot: number): Promise<TResponse | undefined> {367const result = await Cache.Instance.get(`${this.namespace}:response:${req.hash}:${cacheSlot}`);368return result ? JSON.parse(result) : undefined;369}370371async set(req: TRequest, cacheSlot: number, value: TResponse): Promise<void> {372await this.locks.withLock(req.hash, async () => {373if (!await this.hasRequest(req.hash)) {374await this.setRequest(req.hash, req);375}376});377378await Cache.Instance.set(`${this.namespace}:response:${req.hash}:${cacheSlot}`, JSON.stringify(value));379}380}381382383