import {ClassicLevel} from "classic-level"; import {Logger} from "@lodestar/utils"; import {DatabaseController, DatabaseOptions, DbBatch, DbReqOpts, FilterOptions, KeyValue} from "./interface.js"; import {LevelDbControllerMetrics} from "./metrics.js"; enum Status { started = "started", closed = "closed", } export interface LevelDBOptions extends DatabaseOptions { db?: ClassicLevel; } export type LevelDbControllerModules = { logger: Logger; metrics?: LevelDbControllerMetrics | null; }; const BUCKET_ID_UNKNOWN = "unknown"; /** Time between capturing metric for db size, every few minutes is sufficient */ const DB_SIZE_METRIC_INTERVAL_MS = 5 * 60 * 1000; /** * The LevelDB implementation of DB */ export class LevelDbController implements DatabaseController { private status = Status.started; private dbSizeMetricInterval?: NodeJS.Timeout; constructor( private readonly logger: Logger, private readonly db: ClassicLevel, private metrics: LevelDbControllerMetrics | null ) { this.metrics = metrics ?? null; if (this.metrics) { this.collectDbSizeMetric(); } } static async create(opts: LevelDBOptions, {metrics, logger}: LevelDbControllerModules): Promise { const db = opts.db || new ClassicLevel(opts.name || "beaconchain", { keyEncoding: "binary", valueEncoding: "binary", multithreading: true, }); try { await db.open(); } catch (e) { if ((e as LevelDbError).cause?.code === "LEVEL_LOCKED") { throw new Error("Database already in use by another process"); } throw e; } return new LevelDbController(logger, db, metrics ?? null); } async close(): Promise { if (this.status === Status.closed) return; this.status = Status.closed; if (this.dbSizeMetricInterval) { clearInterval(this.dbSizeMetricInterval); } await this.db.close(); } /** To inject metrics after CLI initialization */ setMetrics(metrics: LevelDbControllerMetrics): void { if (this.metrics !== null) { throw Error("metrics can only be set once"); } this.metrics = metrics; if (this.status === Status.started) { this.collectDbSizeMetric(); } } async clear(): Promise { await this.db.clear(); } async get(key: Uint8Array, opts?: DbReqOpts): Promise { try { this.metrics?.dbReadReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbReadItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); return (await this.db.get(key)) as Uint8Array | null; } catch (e) { if ((e as LevelDbError).code === "LEVEL_NOT_FOUND") { return null; } throw e; } } /** * Return the multiple items in the order of the given keys * Will return `null` for the keys which does not exists * * https://github.com/Level/abstract-level?tab=readme-ov-file#dbgetmanykeys-options */ async getMany(keys: Uint8Array[], opts?: DbReqOpts): Promise<(Uint8Array | undefined)[]> { this.metrics?.dbReadReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbReadItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, keys.length); return await this.db.getMany(keys); } put(key: Uint8Array, value: Uint8Array, opts?: DbReqOpts): Promise { this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); return this.db.put(key, value); } delete(key: Uint8Array, opts?: DbReqOpts): Promise { this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); return this.db.del(key); } batchPut(items: KeyValue[], opts?: DbReqOpts): Promise { this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, items.length); return this.db.batch(items.map((item) => ({type: "put", key: item.key, value: item.value}))); } batchDelete(keys: Uint8Array[], opts?: DbReqOpts): Promise { this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, keys.length); return this.db.batch(keys.map((key) => ({type: "del", key: key}))); } batch(batch: DbBatch, opts?: DbReqOpts): Promise { this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1); this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, batch.length); return this.db.batch(batch); } keysStream(opts: FilterOptions = {}): AsyncIterable { return this.metricsIterator(this.db.keys(opts), (key) => key, opts.bucketId ?? BUCKET_ID_UNKNOWN); } valuesStream(opts: FilterOptions = {}): AsyncIterable { return this.metricsIterator(this.db.values(opts), (value) => value, opts.bucketId ?? BUCKET_ID_UNKNOWN); } entriesStream(opts: FilterOptions = {}): AsyncIterable> { return this.metricsIterator( this.db.iterator(opts), (entry) => ({key: entry[0], value: entry[1]}), opts.bucketId ?? BUCKET_ID_UNKNOWN ); } keys(opts: FilterOptions = {}): Promise { return this.metricsAll(this.db.keys(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN); } values(opts: FilterOptions = {}): Promise { return this.metricsAll(this.db.values(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN); } async entries(opts: FilterOptions = {}): Promise[]> { const entries = await this.metricsAll(this.db.iterator(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN); return entries.map((entry) => ({key: entry[0], value: entry[1]})); } /** * Get the approximate number of bytes of file system space used by the range [start..end). * The result might not include recently written data. */ approximateSize(start: Uint8Array, end: Uint8Array): Promise { return this.db.approximateSize(start, end); } /** * Manually trigger a database compaction in the range [start..end]. */ compactRange(start: Uint8Array, end: Uint8Array): Promise { return this.db.compactRange(start, end); } /** Capture metrics for db.iterator, db.keys, db.values .all() calls */ private async metricsAll(promise: Promise, bucket: string): Promise { this.metrics?.dbReadReq.inc({bucket}, 1); const items = await promise; this.metrics?.dbReadItems.inc({bucket}, items.length); return items; } /** Capture metrics for db.iterator, db.keys, db.values AsyncIterable calls */ private async *metricsIterator( iterator: AsyncIterable, getValue: (item: T) => K, bucket: string ): AsyncIterable { this.metrics?.dbReadReq.inc({bucket}, 1); let itemsRead = 0; for await (const item of iterator) { // Count metrics after done condition itemsRead++; yield getValue(item); } this.metrics?.dbReadItems.inc({bucket}, itemsRead); } /** Start interval to capture metric for db size */ private collectDbSizeMetric(): void { this.dbSizeMetric(); this.dbSizeMetricInterval = setInterval(this.dbSizeMetric.bind(this), DB_SIZE_METRIC_INTERVAL_MS); } /** Capture metric for db size */ private dbSizeMetric(): void { const timer = this.metrics?.dbApproximateSizeTime.startTimer(); const minKey = Buffer.from([0x00]); const maxKey = Buffer.from([0xff]); this.approximateSize(minKey, maxKey) .then((dbSize) => { this.metrics?.dbSizeTotal.set(dbSize); }) .catch((e) => { this.logger.debug("Error approximating db size", {}, e); }) .finally(timer); } static async destroy(location: string): Promise { return ClassicLevel.destroy(location); } } /** From https://www.npmjs.com/package/level */ type LevelDbError = {code: "LEVEL_NOT_FOUND"; cause?: {code: "LEVEL_LOCKED"}};