From 20d2444f5a5b5c4e4f25405bc89b85dd65fb94be Mon Sep 17 00:00:00 2001 From: Vasyl Ivanchuk Date: Wed, 15 Jan 2025 17:27:31 +0200 Subject: [PATCH] feat: add missing blocks metric (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What ❔ Add missing blocks metric. ## Why ❔ To detect when there are missing blocks in the DB. Issue with missing blocks is described [here](https://github.com/matter-labs/block-explorer/issues/370). ## Checklist - [X] PR title corresponds to the body of PR (we generate changelog entries from PRs). - [X] Tests for the changes have been added / updated. --- packages/app/src/components/TheWelcome.vue | 2 +- .../app/src/components/header/TheHeader.vue | 2 +- packages/app/src/views/MaintenanceView.vue | 4 +- .../app/tests/components/TheHeader.spec.ts | 2 +- .../features/artifacts/artifactsSet1.feature | 4 +- .../redirection/redirectionSet1.feature | 8 +- .../e2e/src/steps/blockexplorer.steps.ts | 10 +++ .../app/tests/views/MaintenanceView.spec.ts | 4 +- packages/worker/.env.example | 3 + .../worker/src/block/block.watcher.spec.ts | 88 +++++++++++++++++++ packages/worker/src/block/block.watcher.ts | 24 +++++ packages/worker/src/config.spec.ts | 8 ++ packages/worker/src/config.ts | 6 ++ .../worker/src/metrics/metrics.provider.ts | 5 ++ .../src/repositories/block.repository.spec.ts | 31 +++++++ .../src/repositories/block.repository.ts | 9 ++ 16 files changed, 197 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/TheWelcome.vue b/packages/app/src/components/TheWelcome.vue index cbafc0078d..a02a821402 100644 --- a/packages/app/src/components/TheWelcome.vue +++ b/packages/app/src/components/TheWelcome.vue @@ -64,7 +64,7 @@ import ToolingIcon from "@/components/icons/IconTooling.vue"; Vue Land, our official Discord server, or StackOverflow. You should also subscribe to our mailing list and follow the official - @vuejs + @vuejs twitter account for latest news in the Vue world. diff --git a/packages/app/src/components/header/TheHeader.vue b/packages/app/src/components/header/TheHeader.vue index f92cc6592e..a666cc455b 100644 --- a/packages/app/src/components/header/TheHeader.vue +++ b/packages/app/src/components/header/TheHeader.vue @@ -204,7 +204,7 @@ const toolsLinks = reactive(links); const socials = [ { url: "https://join.zksync.dev/", component: DiscordIcon }, - { url: "https://twitter.com/zksync", component: TwitterIcon }, + { url: "https://x.com/zksync", component: TwitterIcon }, ]; const hasContent = computed(() => { diff --git a/packages/app/src/views/MaintenanceView.vue b/packages/app/src/views/MaintenanceView.vue index d89df090ec..a6b835db8d 100644 --- a/packages/app/src/views/MaintenanceView.vue +++ b/packages/app/src/views/MaintenanceView.vue @@ -8,13 +8,13 @@ {{ currentNetwork.l2NetworkName }} - diff --git a/packages/app/tests/components/TheHeader.spec.ts b/packages/app/tests/components/TheHeader.spec.ts index e46d560794..f96c041026 100644 --- a/packages/app/tests/components/TheHeader.spec.ts +++ b/packages/app/tests/components/TheHeader.spec.ts @@ -74,7 +74,7 @@ describe("TheHeader:", () => { }); const routerArray = wrapper.findAll(".socials-container > a"); expect(routerArray[0].attributes("href")).toBe("https://join.zksync.dev/"); - expect(routerArray[1].attributes("href")).toBe("https://twitter.com/zksync"); + expect(routerArray[1].attributes("href")).toBe("https://x.com/zksync"); }); it("renders network switch", () => { const wrapper = mount(TheHeader, { diff --git a/packages/app/tests/e2e/features/artifacts/artifactsSet1.feature b/packages/app/tests/e2e/features/artifacts/artifactsSet1.feature index 332cc2673d..1940aad64b 100644 --- a/packages/app/tests/e2e/features/artifacts/artifactsSet1.feature +++ b/packages/app/tests/e2e/features/artifacts/artifactsSet1.feature @@ -39,9 +39,9 @@ Feature: Main Page | Value | url | # discord renamed to "join" | join | https://join.zksync.dev/ | - | twitter | https://twitter.com/zksync | + | x.com | https://x.com/zksync | - @id254:I + @id254: Scenario Outline: Check dropdown "" for "" and verify Given Set the "" value for "" switcher Then Check the "" value is actual for "" switcher diff --git a/packages/app/tests/e2e/features/redirection/redirectionSet1.feature b/packages/app/tests/e2e/features/redirection/redirectionSet1.feature index 3cf0dadec0..aebb2349e5 100644 --- a/packages/app/tests/e2e/features/redirection/redirectionSet1.feature +++ b/packages/app/tests/e2e/features/redirection/redirectionSet1.feature @@ -21,13 +21,13 @@ Feature: Redirection @id231 Scenario Outline: Verify redirection for "" social network icon on Header When I click by element with partial href "" - Then New page have "" address + Then New page address matches the "" Examples: - | Icon | url | + | Icon | regexp | # discord renamed to "join" - | join | https://join.zksync.dev/ | - | twitter | https://x.com/zksync | + | join | ^https://join.zksync.dev/$ | + | x.com | ^https://x.com/zksync(\\?.*)?$ | @id251 Scenario: Verify redirection for Documentation link diff --git a/packages/app/tests/e2e/src/steps/blockexplorer.steps.ts b/packages/app/tests/e2e/src/steps/blockexplorer.steps.ts index f36b96826a..6f1728467d 100644 --- a/packages/app/tests/e2e/src/steps/blockexplorer.steps.ts +++ b/packages/app/tests/e2e/src/steps/blockexplorer.steps.ts @@ -88,6 +88,16 @@ Then("New page have {string} address", async function (this: ICustomWorld, url: await expect(result).toBe(url); }); +Then("New page address matches the {string}", async function (this: ICustomWorld, regexp: string) { + mainPage = new MainPage(this); + helper = new Helper(this); + await this.page?.waitForTimeout(config.increasedTimeout.timeout); + const pages: any = this.context?.pages(); + + result = await pages[1].url(); + await expect(RegExp(regexp).test(result)).toBe(true); +}); + Given("I go to page {string}", async function (this: ICustomWorld, route: string) { await this.page?.goto(config.BASE_URL + route + config.DAPP_NETWORK); await this.page?.waitForLoadState(); diff --git a/packages/app/tests/views/MaintenanceView.spec.ts b/packages/app/tests/views/MaintenanceView.spec.ts index b205c52ec0..d2df9da066 100644 --- a/packages/app/tests/views/MaintenanceView.spec.ts +++ b/packages/app/tests/views/MaintenanceView.spec.ts @@ -46,9 +46,9 @@ describe("MaintenanceView:", () => { }, }); - expect(wrapper.findAll(`.description a`)[0].attributes("href")).toBe("https://twitter.com/ZKsyncDevs"); + expect(wrapper.findAll(`.description a`)[0].attributes("href")).toBe("https://x.com/ZKsyncDevs"); expect(wrapper.findAll(`.description a`)[1].attributes("href")).toBe("https://uptime.com/statuspage/era"); - expect(wrapper.find(`.twitter-button`).attributes("href")).toBe("https://twitter.com/ZKsyncDevs"); + expect(wrapper.find(`.twitter-button`).attributes("href")).toBe("https://x.com/ZKsyncDevs"); expect(wrapper.find(`.uptime-link`).attributes("href")).toBe("https://uptime.com/statuspage/era"); }); }); diff --git a/packages/worker/.env.example b/packages/worker/.env.example index 91bca5bafa..d9773b9d8b 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -30,6 +30,9 @@ RPC_BATCH_STALL_TIME_MS=0 COLLECT_DB_CONNECTION_POOL_METRICS_INTERVAL=10000 COLLECT_BLOCKS_TO_PROCESS_METRIC_INTERVAL=10000 +DISABLE_MISSING_BLOCKS_METRIC=false +CHECK_MISSING_BLOCKS_METRIC_INTERVAL=86400000 + DISABLE_BATCHES_PROCESSING=false DISABLE_COUNTERS_PROCESSING=false DISABLE_OLD_BALANCES_CLEANER=false diff --git a/packages/worker/src/block/block.watcher.spec.ts b/packages/worker/src/block/block.watcher.spec.ts index 6a4396e561..c3acfa6688 100644 --- a/packages/worker/src/block/block.watcher.spec.ts +++ b/packages/worker/src/block/block.watcher.spec.ts @@ -5,6 +5,7 @@ import { ConfigService } from "@nestjs/config"; import { DataFetcherService } from "../dataFetcher/dataFetcher.service"; import { BlockWatcher } from "./block.watcher"; import { BlockchainService } from "../blockchain"; +import { BlockRepository } from "../repositories"; jest.useFakeTimers(); @@ -18,14 +19,20 @@ describe("BlockWatcher", () => { let dataFetcherServiceMock: DataFetcherService; let blockchainBlocksMetricMock: jest.Mock; let blocksToProcessMetricMock: jest.Mock; + let missingBlocksMetricMock: jest.Mock; let getBlockInfoDurationMetricStartMock: jest.Mock; let getBlockInfoDurationMetricStopMock: jest.Mock; let configServiceMock: ConfigService; + let blockRepositoryMock: BlockRepository; const getBlockWatcher = async () => { const app = await Test.createTestingModule({ providers: [ BlockWatcher, + { + provide: BlockRepository, + useValue: blockRepositoryMock, + }, { provide: BlockchainService, useValue: blockchainServiceMock, @@ -46,6 +53,12 @@ describe("BlockWatcher", () => { set: blocksToProcessMetricMock, }, }, + { + provide: "PROM_METRIC_MISSING_BLOCKS", + useValue: { + set: missingBlocksMetricMock, + }, + }, { provide: "PROM_METRIC_GET_BLOCK_INFO_DURATION_SECONDS", useValue: { @@ -67,8 +80,12 @@ describe("BlockWatcher", () => { beforeEach(async () => { blockchainBlocksMetricMock = jest.fn(); blocksToProcessMetricMock = jest.fn(); + missingBlocksMetricMock = jest.fn(); getBlockInfoDurationMetricStopMock = jest.fn(); getBlockInfoDurationMetricStartMock = jest.fn().mockReturnValue(getBlockInfoDurationMetricStopMock); + blockRepositoryMock = mock({ + getMissingBlocksCount: jest.fn().mockResolvedValue(50), + }); configServiceMock = mock({ get: jest.fn().mockImplementation((key: string) => { if (key === "blocks.blocksProcessingBatchSize") { @@ -77,6 +94,10 @@ describe("BlockWatcher", () => { return 0; } else if (key === "metrics.collectBlocksToProcessMetricInterval") { return 10000; + } else if (key === "metrics.missingBlocks.disabled") { + return true; + } else if (key === "metrics.missingBlocks.interval") { + return 20000; } return null; }), @@ -446,6 +467,39 @@ describe("BlockWatcher", () => { expect(blocksToProcessMetricMock).toBeCalledWith(0); }); }); + + describe("when missing blocks metric is disabled", () => { + beforeEach(async () => { + (configServiceMock.get as jest.Mock).mockImplementation((key: string) => { + if (key === "metrics.missingBlocks.disabled") return true; + if (key === "metrics.missingBlocks.interval") return 1000; + }); + blockWatcher = await getBlockWatcher(); + }); + + it("does not set the metric", async () => { + await blockWatcher.onModuleInit(); + expect(blockRepositoryMock.getMissingBlocksCount).toHaveBeenCalledTimes(0); + expect(missingBlocksMetricMock).toBeCalledTimes(0); + }); + }); + + describe("when missing blocks metric is enabled", () => { + beforeEach(async () => { + (configServiceMock.get as jest.Mock).mockImplementation((key: string) => { + if (key === "metrics.missingBlocks.disabled") return false; + if (key === "metrics.missingBlocks.interval") return 1000; + }); + blockWatcher = await getBlockWatcher(); + }); + + it("sets the metric to the proper value", async () => { + await blockWatcher.onModuleInit(); + expect(blockRepositoryMock.getMissingBlocksCount).toHaveBeenCalledTimes(1); + expect(missingBlocksMetricMock).toBeCalledTimes(1); + expect(missingBlocksMetricMock).toBeCalledWith(50); + }); + }); }); describe("onModuleDestroy", () => { @@ -454,5 +508,39 @@ describe("BlockWatcher", () => { blockWatcher.onModuleDestroy(); expect(global.clearInterval).toBeCalledWith(timer); }); + + describe("when missing blocks metric is disabled", () => { + beforeEach(async () => { + (configServiceMock.get as jest.Mock).mockImplementation((key: string) => { + if (key === "metrics.missingBlocks.disabled") return true; + if (key === "metrics.missingBlocks.interval") return 1000; + }); + blockWatcher = await getBlockWatcher(); + }); + + it("does not clear the interval for the metric", async () => { + await blockWatcher.onModuleInit(); + blockWatcher.onModuleDestroy(); + // first call is for blocks to process metric + expect(global.clearInterval).toBeCalledTimes(1); + }); + }); + + describe("when missing blocks metric is enabled", () => { + beforeEach(async () => { + (configServiceMock.get as jest.Mock).mockImplementation((key: string) => { + if (key === "metrics.missingBlocks.disabled") return false; + if (key === "metrics.missingBlocks.interval") return 1000; + }); + blockWatcher = await getBlockWatcher(); + }); + + it("clears the interval for the metric", async () => { + await blockWatcher.onModuleInit(); + blockWatcher.onModuleDestroy(); + // first call is for blocks to process metric + expect(global.clearInterval).toBeCalledTimes(2); + }); + }); }); }); diff --git a/packages/worker/src/block/block.watcher.ts b/packages/worker/src/block/block.watcher.ts index abd1047d35..f491c6554a 100644 --- a/packages/worker/src/block/block.watcher.ts +++ b/packages/worker/src/block/block.watcher.ts @@ -5,9 +5,11 @@ import { Gauge, Histogram } from "prom-client"; import { DataFetcherService } from "../dataFetcher/dataFetcher.service"; import { BlockData } from "../dataFetcher/types"; import { BlockchainService } from "../blockchain/blockchain.service"; +import { BlockRepository } from "../repositories"; import { BLOCKCHAIN_BLOCKS_METRIC_NAME, BLOCKS_TO_PROCESS_METRIC_NAME, + MISSING_BLOCKS_METRIC_NAME, GET_BLOCK_INFO_DURATION_METRIC_NAME, ProcessingActionMetricLabel, } from "../metrics"; @@ -20,6 +22,9 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { private readonly batchSize: number; private readonly fromBlock: number; private readonly toBlock: number; + private readonly missingBlocksMetricEnabled: boolean; + private readonly missingBlocksMetricInterval: number; + private missingBlocksMetricTimer: NodeJS.Timer = null; private collectBlocksToProcessMetricInterval: number; private collectBlocksToProcessMetricTimer: NodeJS.Timer = null; @@ -28,12 +33,15 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { } public constructor( + private readonly blockRepository: BlockRepository, private readonly blockchainService: BlockchainService, private readonly dataFetchService: DataFetcherService, @InjectMetric(BLOCKCHAIN_BLOCKS_METRIC_NAME) private readonly blockchainBlocksMetric: Gauge, @InjectMetric(BLOCKS_TO_PROCESS_METRIC_NAME) private readonly blocksToProcessMetric: Gauge, + @InjectMetric(MISSING_BLOCKS_METRIC_NAME) + private readonly missingBlocksMetric: Gauge, @InjectMetric(GET_BLOCK_INFO_DURATION_METRIC_NAME) private readonly getBlockInfoDurationMetric: Histogram, configService: ConfigService @@ -45,6 +53,8 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { this.collectBlocksToProcessMetricInterval = configService.get( "metrics.collectBlocksToProcessMetricInterval" ); + this.missingBlocksMetricEnabled = !configService.get("metrics.missingBlocks.disabled"); + this.missingBlocksMetricInterval = configService.get("metrics.missingBlocks.interval"); } public async getNextBlocksToProcess(lastDbBlockNumber: number = null): Promise { @@ -79,6 +89,11 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { } } + private async updateMissingBlocksMetric(): Promise { + const missingBlocksCount = await this.blockRepository.getMissingBlocksCount(); + this.missingBlocksMetric.set(missingBlocksCount); + } + private getBlockInfoListFromBlockchain(startBlockNumber: number, endBlockNumber: number): Promise { const getBlockInfoTasks = []; for (let blockNumber = startBlockNumber; blockNumber <= endBlockNumber; blockNumber++) { @@ -112,9 +127,18 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { this.collectBlocksToProcessMetricTimer = setInterval(() => { this.setBlocksToProcessMetric(); }, this.collectBlocksToProcessMetricInterval); + + if (this.missingBlocksMetricEnabled) { + this.missingBlocksMetricTimer = setInterval(() => { + this.updateMissingBlocksMetric(); + }, this.missingBlocksMetricInterval); + } } public onModuleDestroy() { clearInterval(this.collectBlocksToProcessMetricTimer as unknown as number); + if (this.missingBlocksMetricEnabled) { + clearInterval(this.missingBlocksMetricTimer as unknown as number); + } } } diff --git a/packages/worker/src/config.spec.ts b/packages/worker/src/config.spec.ts index bf01a5b9e4..e6d561388e 100644 --- a/packages/worker/src/config.spec.ts +++ b/packages/worker/src/config.spec.ts @@ -56,6 +56,10 @@ describe("config", () => { metrics: { collectDbConnectionPoolMetricsInterval: 10000, collectBlocksToProcessMetricInterval: 10000, + missingBlocks: { + disabled: false, + interval: 86_400_000, + }, }, }; }); @@ -114,6 +118,10 @@ describe("config", () => { metrics: { collectDbConnectionPoolMetricsInterval: 10000, collectBlocksToProcessMetricInterval: 10000, + missingBlocks: { + disabled: false, + interval: 86_400_000, + }, }, }); }); diff --git a/packages/worker/src/config.ts b/packages/worker/src/config.ts index 331cc11147..ba43f72596 100644 --- a/packages/worker/src/config.ts +++ b/packages/worker/src/config.ts @@ -31,6 +31,8 @@ export default () => { TO_BLOCK, COINGECKO_IS_PRO_PLAN, COINGECKO_API_KEY, + DISABLE_MISSING_BLOCKS_METRIC, + CHECK_MISSING_BLOCKS_METRIC_INTERVAL, } = process.env; return { @@ -90,6 +92,10 @@ export default () => { metrics: { collectDbConnectionPoolMetricsInterval: parseInt(COLLECT_DB_CONNECTION_POOL_METRICS_INTERVAL, 10) || 10000, collectBlocksToProcessMetricInterval: parseInt(COLLECT_BLOCKS_TO_PROCESS_METRIC_INTERVAL, 10) || 10000, + missingBlocks: { + disabled: DISABLE_MISSING_BLOCKS_METRIC === "true", + interval: parseInt(CHECK_MISSING_BLOCKS_METRIC_INTERVAL, 10) || 86_400_000, // 1 day + }, }, }; }; diff --git a/packages/worker/src/metrics/metrics.provider.ts b/packages/worker/src/metrics/metrics.provider.ts index 6f37431dcd..1f15068863 100644 --- a/packages/worker/src/metrics/metrics.provider.ts +++ b/packages/worker/src/metrics/metrics.provider.ts @@ -23,6 +23,7 @@ export type BlockchainRpcCallMetricLabel = "function"; export const BLOCKCHAIN_BLOCKS_METRIC_NAME = "blockchain_blocks"; export const BLOCKS_TO_PROCESS_METRIC_NAME = "blocks_to_process"; +export const MISSING_BLOCKS_METRIC_NAME = "missing_blocks"; export const BLOCKS_REVERT_DURATION_METRIC_NAME = "blocks_revert_duration_seconds"; export const BLOCKS_REVERT_DETECT_METRIC_NAME = "blocks_revert_detect"; @@ -96,6 +97,10 @@ export const metricProviders: Provider[] = [ name: BLOCKS_TO_PROCESS_METRIC_NAME, help: "total number of remaining blocks to process.", }), + makeGaugeProvider({ + name: MISSING_BLOCKS_METRIC_NAME, + help: "total number of missing blocks should be 0. A value > 0 indicates an issue that should be investigated.", + }), makeHistogramProvider({ name: BLOCKS_REVERT_DURATION_METRIC_NAME, help: "revert duration in seconds.", diff --git a/packages/worker/src/repositories/block.repository.spec.ts b/packages/worker/src/repositories/block.repository.spec.ts index 13aa1f1d6c..6aeaab880f 100644 --- a/packages/worker/src/repositories/block.repository.spec.ts +++ b/packages/worker/src/repositories/block.repository.spec.ts @@ -137,6 +137,37 @@ describe("BlockRepository", () => { }); }); + describe("getMissingBlocksCount", () => { + let queryBuilderMock: SelectQueryBuilder; + + beforeEach(() => { + queryBuilderMock = mock>({ + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + count: 50, + }), + }); + + (entityManagerMock.createQueryBuilder as jest.Mock).mockReturnValue(queryBuilderMock); + }); + + it("returns count of missing blocks", async () => { + const result = await repository.getMissingBlocksCount(); + expect(result).toBe(50); + }); + + it("returns 0 when count is not defined", async () => { + (queryBuilderMock.getRawOne as jest.Mock).mockResolvedValueOnce({ count: null }); + const result = await repository.getMissingBlocksCount(); + expect(result).toBe(0); + }); + + it("runs proper query to calculate missing blocks", async () => { + await repository.getMissingBlocksCount(); + expect(queryBuilderMock.select).toBeCalledWith("MAX(number) - COUNT(number) + 1 AS count"); + }); + }); + describe("add", () => { it("adds the block", async () => { await repository.add(blockDto, blockDetailsDto); diff --git a/packages/worker/src/repositories/block.repository.ts b/packages/worker/src/repositories/block.repository.ts index 15aae7350f..e6587b1d02 100644 --- a/packages/worker/src/repositories/block.repository.ts +++ b/packages/worker/src/repositories/block.repository.ts @@ -41,6 +41,15 @@ export class BlockRepository { return lastExecutedBlock?.number || 0; } + public async getMissingBlocksCount(): Promise { + const transactionManager = this.unitOfWork.getTransactionManager(); + const { count } = await transactionManager + .createQueryBuilder(Block, "block") + .select("MAX(number) - COUNT(number) + 1 AS count") // +1 for the block #0 + .getRawOne<{ count: number }>(); + return Number(count); + } + public async add(blockDto: BlockDto, blockDetailsDto: types.BlockDetails): Promise { const transactionManager = this.unitOfWork.getTransactionManager(); await transactionManager.insert(Block, {