Path: blob/main/src/vs/base/parts/storage/common/storage.ts
3296 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 { ThrottledDelayer } from '../../../common/async.js';6import { Event, PauseableEmitter } from '../../../common/event.js';7import { Disposable, IDisposable } from '../../../common/lifecycle.js';8import { parse, stringify } from '../../../common/marshalling.js';9import { isObject, isUndefinedOrNull } from '../../../common/types.js';1011export enum StorageHint {1213// A hint to the storage that the storage14// does not exist on disk yet. This allows15// the storage library to improve startup16// time by not checking the storage for data.17STORAGE_DOES_NOT_EXIST,1819// A hint to the storage that the storage20// is backed by an in-memory storage.21STORAGE_IN_MEMORY22}2324export interface IStorageOptions {25readonly hint?: StorageHint;26}2728export interface IUpdateRequest {29readonly insert?: Map<string, string>;30readonly delete?: Set<string>;31}3233export interface IStorageItemsChangeEvent {34readonly changed?: Map<string, string>;35readonly deleted?: Set<string>;36}3738export function isStorageItemsChangeEvent(thing: unknown): thing is IStorageItemsChangeEvent {39const candidate = thing as IStorageItemsChangeEvent | undefined;4041return candidate?.changed instanceof Map || candidate?.deleted instanceof Set;42}4344export interface IStorageDatabase {4546readonly onDidChangeItemsExternal: Event<IStorageItemsChangeEvent>;4748getItems(): Promise<Map<string, string>>;49updateItems(request: IUpdateRequest): Promise<void>;5051optimize(): Promise<void>;5253close(recovery?: () => Map<string, string>): Promise<void>;54}5556export interface IStorageChangeEvent {5758/**59* The `key` of the storage entry that was changed60* or was removed.61*/62readonly key: string;6364/**65* A hint how the storage change event was triggered. If66* `true`, the storage change was triggered by an external67* source, such as:68* - another process (for example another window)69* - operations such as settings sync or profiles change70*/71readonly external?: boolean;72}7374export type StorageValue = string | boolean | number | undefined | null | object;7576export interface IStorage extends IDisposable {7778readonly onDidChangeStorage: Event<IStorageChangeEvent>;7980readonly items: Map<string, string>;81readonly size: number;8283init(): Promise<void>;8485get(key: string, fallbackValue: string): string;86get(key: string, fallbackValue?: string): string | undefined;8788getBoolean(key: string, fallbackValue: boolean): boolean;89getBoolean(key: string, fallbackValue?: boolean): boolean | undefined;9091getNumber(key: string, fallbackValue: number): number;92getNumber(key: string, fallbackValue?: number): number | undefined;9394getObject<T extends object>(key: string, fallbackValue: T): T;95getObject<T extends object>(key: string, fallbackValue?: T): T | undefined;9697set(key: string, value: StorageValue, external?: boolean): Promise<void>;98delete(key: string, external?: boolean): Promise<void>;99100flush(delay?: number): Promise<void>;101whenFlushed(): Promise<void>;102103optimize(): Promise<void>;104105close(): Promise<void>;106}107108export enum StorageState {109None,110Initialized,111Closed112}113114export class Storage extends Disposable implements IStorage {115116private static readonly DEFAULT_FLUSH_DELAY = 100;117118private readonly _onDidChangeStorage = this._register(new PauseableEmitter<IStorageChangeEvent>());119readonly onDidChangeStorage = this._onDidChangeStorage.event;120121private state = StorageState.None;122123private cache = new Map<string, string>();124125private readonly flushDelayer = this._register(new ThrottledDelayer<void>(Storage.DEFAULT_FLUSH_DELAY));126127private pendingDeletes = new Set<string>();128private pendingInserts = new Map<string, string>();129130private pendingClose: Promise<void> | undefined = undefined;131132private readonly whenFlushedCallbacks: Function[] = [];133134constructor(135protected readonly database: IStorageDatabase,136private readonly options: IStorageOptions = Object.create(null)137) {138super();139140this.registerListeners();141}142143private registerListeners(): void {144this._register(this.database.onDidChangeItemsExternal(e => this.onDidChangeItemsExternal(e)));145}146147private onDidChangeItemsExternal(e: IStorageItemsChangeEvent): void {148this._onDidChangeStorage.pause();149150try {151// items that change external require us to update our152// caches with the values. we just accept the value and153// emit an event if there is a change.154155e.changed?.forEach((value, key) => this.acceptExternal(key, value));156e.deleted?.forEach(key => this.acceptExternal(key, undefined));157158} finally {159this._onDidChangeStorage.resume();160}161}162163private acceptExternal(key: string, value: string | undefined): void {164if (this.state === StorageState.Closed) {165return; // Return early if we are already closed166}167168let changed = false;169170// Item got removed, check for deletion171if (isUndefinedOrNull(value)) {172changed = this.cache.delete(key);173}174175// Item got updated, check for change176else {177const currentValue = this.cache.get(key);178if (currentValue !== value) {179this.cache.set(key, value);180changed = true;181}182}183184// Signal to outside listeners185if (changed) {186this._onDidChangeStorage.fire({ key, external: true });187}188}189190get items(): Map<string, string> {191return this.cache;192}193194get size(): number {195return this.cache.size;196}197198async init(): Promise<void> {199if (this.state !== StorageState.None) {200return; // either closed or already initialized201}202203this.state = StorageState.Initialized;204205if (this.options.hint === StorageHint.STORAGE_DOES_NOT_EXIST) {206// return early if we know the storage file does not exist. this is a performance207// optimization to not load all items of the underlying storage if we know that208// there can be no items because the storage does not exist.209return;210}211212this.cache = await this.database.getItems();213}214215get(key: string, fallbackValue: string): string;216get(key: string, fallbackValue?: string): string | undefined;217get(key: string, fallbackValue?: string): string | undefined {218const value = this.cache.get(key);219220if (isUndefinedOrNull(value)) {221return fallbackValue;222}223224return value;225}226227getBoolean(key: string, fallbackValue: boolean): boolean;228getBoolean(key: string, fallbackValue?: boolean): boolean | undefined;229getBoolean(key: string, fallbackValue?: boolean): boolean | undefined {230const value = this.get(key);231232if (isUndefinedOrNull(value)) {233return fallbackValue;234}235236return value === 'true';237}238239getNumber(key: string, fallbackValue: number): number;240getNumber(key: string, fallbackValue?: number): number | undefined;241getNumber(key: string, fallbackValue?: number): number | undefined {242const value = this.get(key);243244if (isUndefinedOrNull(value)) {245return fallbackValue;246}247248return parseInt(value, 10);249}250251getObject(key: string, fallbackValue: object): object;252getObject(key: string, fallbackValue?: object | undefined): object | undefined;253getObject(key: string, fallbackValue?: object): object | undefined {254const value = this.get(key);255256if (isUndefinedOrNull(value)) {257return fallbackValue;258}259260return parse(value);261}262263async set(key: string, value: string | boolean | number | null | undefined | object, external = false): Promise<void> {264if (this.state === StorageState.Closed) {265return; // Return early if we are already closed266}267268// We remove the key for undefined/null values269if (isUndefinedOrNull(value)) {270return this.delete(key, external);271}272273// Otherwise, convert to String and store274const valueStr = isObject(value) || Array.isArray(value) ? stringify(value) : String(value);275276// Return early if value already set277const currentValue = this.cache.get(key);278if (currentValue === valueStr) {279return;280}281282// Update in cache and pending283this.cache.set(key, valueStr);284this.pendingInserts.set(key, valueStr);285this.pendingDeletes.delete(key);286287// Event288this._onDidChangeStorage.fire({ key, external });289290// Accumulate work by scheduling after timeout291return this.doFlush();292}293294async delete(key: string, external = false): Promise<void> {295if (this.state === StorageState.Closed) {296return; // Return early if we are already closed297}298299// Remove from cache and add to pending300const wasDeleted = this.cache.delete(key);301if (!wasDeleted) {302return; // Return early if value already deleted303}304305if (!this.pendingDeletes.has(key)) {306this.pendingDeletes.add(key);307}308309this.pendingInserts.delete(key);310311// Event312this._onDidChangeStorage.fire({ key, external });313314// Accumulate work by scheduling after timeout315return this.doFlush();316}317318async optimize(): Promise<void> {319if (this.state === StorageState.Closed) {320return; // Return early if we are already closed321}322323// Await pending data to be flushed to the DB324// before attempting to optimize the DB325await this.flush(0);326327return this.database.optimize();328}329330async close(): Promise<void> {331if (!this.pendingClose) {332this.pendingClose = this.doClose();333}334335return this.pendingClose;336}337338private async doClose(): Promise<void> {339340// Update state341this.state = StorageState.Closed;342343// Trigger new flush to ensure data is persisted and then close344// even if there is an error flushing. We must always ensure345// the DB is closed to avoid corruption.346//347// Recovery: we pass our cache over as recovery option in case348// the DB is not healthy.349try {350await this.doFlush(0 /* as soon as possible */);351} catch (error) {352// Ignore353}354355await this.database.close(() => this.cache);356}357358private get hasPending() {359return this.pendingInserts.size > 0 || this.pendingDeletes.size > 0;360}361362private async flushPending(): Promise<void> {363if (!this.hasPending) {364return; // return early if nothing to do365}366367// Get pending data368const updateRequest: IUpdateRequest = { insert: this.pendingInserts, delete: this.pendingDeletes };369370// Reset pending data for next run371this.pendingDeletes = new Set<string>();372this.pendingInserts = new Map<string, string>();373374// Update in storage and release any375// waiters we have once done376return this.database.updateItems(updateRequest).finally(() => {377if (!this.hasPending) {378while (this.whenFlushedCallbacks.length) {379this.whenFlushedCallbacks.pop()?.();380}381}382});383}384385async flush(delay?: number): Promise<void> {386if (387this.state === StorageState.Closed || // Return early if we are already closed388this.pendingClose // return early if nothing to do389) {390return;391}392393return this.doFlush(delay);394}395396private async doFlush(delay?: number): Promise<void> {397if (this.options.hint === StorageHint.STORAGE_IN_MEMORY) {398return this.flushPending(); // return early if in-memory399}400401return this.flushDelayer.trigger(() => this.flushPending(), delay);402}403404async whenFlushed(): Promise<void> {405if (!this.hasPending) {406return; // return early if nothing to do407}408409return new Promise(resolve => this.whenFlushedCallbacks.push(resolve));410}411412isInMemory(): boolean {413return this.options.hint === StorageHint.STORAGE_IN_MEMORY;414}415}416417export class InMemoryStorageDatabase implements IStorageDatabase {418419readonly onDidChangeItemsExternal = Event.None;420421private readonly items = new Map<string, string>();422423async getItems(): Promise<Map<string, string>> {424return this.items;425}426427async updateItems(request: IUpdateRequest): Promise<void> {428request.insert?.forEach((value, key) => this.items.set(key, value));429430request.delete?.forEach(key => this.items.delete(key));431}432433async optimize(): Promise<void> { }434async close(): Promise<void> { }435}436437438