From 7f4cbf8a658c3ee4570f5996f4189701edaa92c6 Mon Sep 17 00:00:00 2001 From: wphan <6348407+wphan@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:39:15 -0800 Subject: [PATCH] healthcheck: add MAX_SLOT_STALENESS_MS and MIN_SLOT_RATE env vars --- src/athena/example.ts | 242 +++++++++++++++++++++++++++++ src/athena/testRedisPolling.ts | 252 +++++++++++++++++++++++++++++++ src/core/healthCheck.ts | 6 +- src/index.ts | 6 +- src/scripts/pollAuctionParams.ts | 152 +++++++++++++++++++ 5 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 src/athena/example.ts create mode 100644 src/athena/testRedisPolling.ts create mode 100644 src/scripts/pollAuctionParams.ts diff --git a/src/athena/example.ts b/src/athena/example.ts new file mode 100644 index 0000000..add5518 --- /dev/null +++ b/src/athena/example.ts @@ -0,0 +1,242 @@ +/** + * Example script demonstrating how to use the Athena integration + * + * Usage: + * ts-node src/athena/example.ts + * + * Make sure to set the following environment variables: + * - AWS_REGION (optional, defaults to us-east-1) + * - AWS_ACCESS_KEY_ID + * - AWS_SECRET_ACCESS_KEY + * - ATHENA_DATABASE (optional, defaults to staging-archive) + * - ATHENA_OUTPUT_BUCKET (optional, defaults to staging-data-ingestion-bucket) + */ + +import { FillQualityAnalyticsRepository } from './repositories/fillQualityAnalytics'; +import { Athena } from './client'; + +require('dotenv').config(); + +async function exampleBasicQuery() { + console.log('\n=== Example 1: Basic Query ===\n'); + + const { query } = Athena(); + + // Simple query to get recent trades + const results = await query(` + SELECT + ts, + markettype, + marketindex, + action, + taker, + maker + FROM eventtype_traderecord + WHERE year = '2024' + AND month = '10' + AND day = '19' + AND markettype = 'perp' + AND marketindex = 0 + LIMIT 10 + `); + + console.log('Results:', JSON.stringify(results, null, 2)); + console.log(`Found ${results.length} trades`); +} + +async function exampleFillQualityAnalytics() { + console.log('\n=== Example 2: Fill Quality Analytics ===\n'); + + const repository = FillQualityAnalyticsRepository(); + + // Get taker fill vs oracle bps for ALL perp markets + // Last 24 hours + const filterMarket = '15'; + const smoothingMinutes = 60; + const nowMs = Date.now(); + const yesterdayMs = nowMs - 24 * (smoothingMinutes * 60 * 1000); + + console.log( + `Querying from ${new Date(yesterdayMs).toISOString()} to ${new Date( + nowMs + ).toISOString()}` + ); + + const results = await repository.getTakerFillVsOracleBps( + yesterdayMs, // from (milliseconds) + nowMs, // to (milliseconds) + 9, // baseDecimals + smoothingMinutes // smoothingMinutes + ); + + console.log(`Retrieved ${results.length} data points across all markets`); + + // Group by market to show summary + const marketGroups = results.reduce((acc, result) => { + const market = result.MarketIndex; + if (!acc[market]) acc[market] = []; + acc[market].push(result); + return acc; + }, {} as Record); + + console.log(`\nFound data for ${Object.keys(marketGroups).length} markets`); + + // Show last few results for the first market (last results have full rolling window data) + if (results.length > 0) { + // const firstMarket = Object.keys(marketGroups)[0]; + // const firstMarketData = marketGroups[firstMarket]; + // console.log(`\nLast 3 results for Market ${firstMarket} (total: ${firstMarketData.length} rows):`); + // firstMarketData.slice(-3).forEach((result, i) => { + // console.log(`\n[${firstMarketData.length - 2 + i}] Time: ${result.Time}, Market: ${result.MarketIndex}`); + // console.log(` Buy BPS ($0-1k): ${result.TakerBuyBpsFromOracle_1e0}`); + // console.log(` Buy BPS (>$1M): ${result.TakerBuyBpsFromOracle_1e6}`); + // console.log(` Sell BPS ($0-1k): ${result.TakerSellBpsFromOracle_1e0}`); + // console.log(` Sell BPS (>$1M): ${result.TakerSellBpsFromOracle_1e6}`); + // }); + + // Find and show some non-null results + console.log('\n--- Looking for non-null results ---'); + const nonNullResults = results.filter( + (r) => + (r.TakerBuyBpsFromOracle_1e0 !== null || + r.TakerSellBpsFromOracle_1e0 !== null || + r.TakerBuyBpsFromOracle_1e3 !== null || + r.TakerSellBpsFromOracle_1e3 !== null || + r.TakerBuyBpsFromOracle_1e4 !== null || + r.TakerSellBpsFromOracle_1e4 !== null || + r.TakerBuyBpsFromOracle_1e5 !== null || + r.TakerSellBpsFromOracle_1e5 !== null || + r.TakerBuyBpsFromOracle_1e6 !== null || + r.TakerSellBpsFromOracle_1e6 !== null || + r.TakerBuyBpsFromOracle_ALL !== null || + r.TakerSellBpsFromOracle_ALL !== null) && + r.MarketIndex === filterMarket + ); + console.log( + `Found ${nonNullResults.length} rows with non-null BPS values (${( + (nonNullResults.length / results.length) * + 100 + ).toFixed(1)}% of total)` + ); + // console.log(results.filter(r => r.MarketIndex === filterMarket)); + + const uniqueMarketIndexes = [ + ...new Set(results.map((r) => Number(r.MarketIndex))), + ].sort((a, b) => a - b); + console.log( + `Unique market indexes (total: ${ + uniqueMarketIndexes.length + }): ${uniqueMarketIndexes.join(', ')}` + ); + + if (nonNullResults.length > 0) { + console.log('\nLatest non-null result for Market 0:'); + const sample = nonNullResults[nonNullResults.length - 1]; + console.log(`Time: ${sample.Time}, Market: ${sample.MarketIndex}`); + console.log( + ` Buy BPS ($0-1k): ${sample.TakerBuyBpsFromOracle_1e0}` + ); + console.log( + ` Buy BPS ($1k-10k): ${sample.TakerBuyBpsFromOracle_1e3}` + ); + console.log( + ` Buy BPS ($10k-100k): ${sample.TakerBuyBpsFromOracle_1e4}` + ); + console.log( + ` Buy BPS ($100k-1M): ${sample.TakerBuyBpsFromOracle_1e5}` + ); + console.log( + ` Buy BPS (>$1M): ${sample.TakerBuyBpsFromOracle_1e6}` + ); + console.log( + ` Buy BPS (ALL): ${sample.TakerBuyBpsFromOracle_ALL}` + ); + console.log( + ` Sell BPS ($0-1k): ${sample.TakerSellBpsFromOracle_1e0}` + ); + console.log( + ` Sell BPS ($1k-10k): ${sample.TakerSellBpsFromOracle_1e3}` + ); + console.log( + ` Sell BPS ($10k-100k): ${sample.TakerSellBpsFromOracle_1e4}` + ); + console.log( + ` Sell BPS ($100k-1M): ${sample.TakerSellBpsFromOracle_1e5}` + ); + console.log( + ` Sell BPS (>$1M): ${sample.TakerSellBpsFromOracle_1e6}` + ); + console.log( + ` Sell BPS (ALL): ${sample.TakerSellBpsFromOracle_ALL}` + ); + } else { + console.log( + '\nāš ļø WARNING: No non-null BPS values found in any results!' + ); + console.log(' This might indicate:'); + console.log(' - No trade data in the time range'); + console.log(' - Orders/trades not joining properly'); + console.log(' - Issue with the query logic'); + } + } + console.log( + `Took ${Date.now() - nowMs}ms to run exampleFillQualityAnalytics` + ); +} + +async function exampleBatchQuery() { + console.log('\n=== Example 3: Batch Query ===\n'); + + const { batchQuery } = Athena(); + + // Run multiple queries in parallel + const { results, errors } = await batchQuery({ + queries: [ + { + query: ` + SELECT COUNT(*) as trade_count + FROM eventtype_traderecord + WHERE year = '2024' AND month = '10' AND day = '19' + AND markettype = 'perp' AND marketindex = 0 + `, + }, + { + query: ` + SELECT COUNT(*) as order_count + FROM eventtype_orderrecord + WHERE year = '2024' AND month = '10' AND day = '19' + AND "order".markettype = 'perp' AND "order".marketindex = 0 + `, + }, + ], + }); + + console.log('Trade count:', results[0]?.[0]?.trade_count || 'N/A'); + console.log('Order count:', results[1]?.[0]?.order_count || 'N/A'); + + if (errors.length > 0) { + console.log('Errors:', errors); + } +} + +async function main() { + console.log('Athena Integration Examples'); + console.log('==========================='); + + try { + // Run examples + // await exampleBasicQuery(); + await exampleFillQualityAnalytics(); + // await exampleBatchQuery(); + + console.log('\nāœ… All examples completed successfully!'); + } catch (error) { + console.error('\nāŒ Error:', error); + process.exit(1); + } +} + +// Run if executed directly +if (require.main === module) { + main(); +} diff --git a/src/athena/testRedisPolling.ts b/src/athena/testRedisPolling.ts new file mode 100644 index 0000000..c52906d --- /dev/null +++ b/src/athena/testRedisPolling.ts @@ -0,0 +1,252 @@ +import { RedisClient, RedisClientPrefix } from '@drift/common/clients'; +import { logger, setLogLevel } from '../utils/logger'; + +setLogLevel('info'); + +const MARKET_INDEXES_TO_TEST = [0, 1, 2]; // SOL-PERP, BTC-PERP, ETH-PERP +const POLL_INTERVAL_MS = 10_000; // Poll every 10 seconds + +interface FillQualityData { + time: string; + marketIndex: string; + takerBuyBpsFromOracle: { + all: string | null; + '1e0': string | null; + '1e3': string | null; + '1e4': string | null; + '1e5': string | null; + '1e6': string | null; + }; + takerSellBpsFromOracle: { + all: string | null; + '1e0': string | null; + '1e3': string | null; + '1e4': string | null; + '1e5': string | null; + '1e6': string | null; + }; + updatedAtTs: number; +} + +interface SummaryData { + markets: string[]; + lastUpdated: string; + lookbackMs: number; + smoothingMinutes: number; +} + +// Track last seen update times for each market +const lastSeenUpdates = new Map(); + +const pollRedis = async (redisClient: RedisClient, iteration: number) => { + logger.info(`\n========== Polling iteration ${iteration} ==========`); + const pollTime = Date.now(); + + try { + // Check summary key + + logger.info('\nšŸ“Š Market Data:'); + + // Check each market + for (const marketIndex of MARKET_INDEXES_TO_TEST) { + const key = `taker_fill_vs_oracle_bps:market:${marketIndex}`; + const dataRaw = await redisClient.get(key); + + if (!dataRaw || typeof dataRaw !== 'string') { + logger.warn(` āŒ Market ${marketIndex}: No data found`); + continue; + } + + const data: FillQualityData = JSON.parse(dataRaw); + const lastSeen = lastSeenUpdates.get(marketIndex); + const isNew = !lastSeen || data.updatedAtTs > lastSeen; + const ageSeconds = Math.floor((pollTime - data.updatedAtTs) / 1000); + + // Update tracking + if (isNew) { + lastSeenUpdates.set(marketIndex, data.updatedAtTs); + } + + const status = isNew ? 'šŸ†• NEW UPDATE' : 'šŸ“Œ unchanged'; + const marketName = + ['SOL-PERP', 'BTC-PERP', 'ETH-PERP'][marketIndex] || + `Market ${marketIndex}`; + + logger.info(` ${status} - ${marketName} (Market ${marketIndex})`); + logger.info(` Data timestamp: ${data.time}`); + logger.info( + ` Updated at: ${new Date(data.updatedAtTs).toISOString()}` + ); + logger.info(` Age: ${ageSeconds}s ago`); + + // Show some sample data + const buyAll = data.takerBuyBpsFromOracle.all; + const sellAll = data.takerSellBpsFromOracle.all; + + if (buyAll !== null && sellAll !== null) { + logger.info( + ` Taker Buy (all): ${parseFloat(buyAll).toFixed(2)} bps` + ); + logger.info( + ` Taker Sell (all): ${parseFloat(sellAll).toFixed(2)} bps` + ); + + // Show cohort breakdown if available + const buy1e6 = data.takerBuyBpsFromOracle['1e6']; + const sell1e6 = data.takerSellBpsFromOracle['1e6']; + if (buy1e6 !== null && sell1e6 !== null) { + logger.info( + ` Large orders (>$1M): Buy=${parseFloat(buy1e6).toFixed( + 2 + )}bps, Sell=${parseFloat(sell1e6).toFixed(2)}bps` + ); + } + } else { + logger.warn(` āš ļø No data available for this market`); + } + + // Warn if data is stale + if (ageSeconds > 600) { + // More than 10 minutes old + logger.warn( + ` āš ļø WARNING: Data is stale (${Math.floor( + ageSeconds / 60 + )} minutes old)` + ); + } + } + + // Summary of updates seen + logger.info('\nšŸ“ˆ Update Tracking:'); + if (iteration === 1) { + logger.info(' First iteration - establishing baseline'); + } else { + const updatedMarkets = MARKET_INDEXES_TO_TEST.filter((idx) => { + const lastSeen = lastSeenUpdates.get(idx); + if (!lastSeen) return false; + const currentRaw = redisClient.get( + `taker_fill_vs_oracle_bps:market:${idx}` + ); + return true; // If we have a last seen, we've tracked it + }); + + if (updatedMarkets.length === MARKET_INDEXES_TO_TEST.length) { + logger.info( + ` āœ… All ${MARKET_INDEXES_TO_TEST.length} markets have data` + ); + } else { + logger.warn( + ` āš ļø Only ${updatedMarkets.length}/${MARKET_INDEXES_TO_TEST.length} markets have data` + ); + } + } + + // Check if keys exist but are not in expected format + logger.info('\nšŸ” Validation:'); + let allValid = true; + for (const marketIndex of MARKET_INDEXES_TO_TEST) { + const key = `taker_fill_vs_oracle_bps:market:${marketIndex}`; + const dataRaw = await redisClient.get(key); + if (dataRaw && typeof dataRaw === 'string') { + try { + const data = JSON.parse(dataRaw); + if (!data.updatedAtTs || !data.marketIndex) { + logger.error(` āŒ Market ${marketIndex}: Invalid data structure`); + allValid = false; + } + } catch (error) { + logger.error( + ` āŒ Market ${marketIndex}: Failed to parse JSON - ${error.message}` + ); + allValid = false; + } + } + } + if (allValid) { + logger.info(' āœ… All data structures are valid'); + } + } catch (error) { + logger.error(`Error polling Redis: ${error.message}`); + logger.error(error.stack); + } +}; + +const main = async () => { + logger.info('šŸš€ Starting Fill Quality Analytics Redis Polling Test'); + logger.info(` Testing markets: ${MARKET_INDEXES_TO_TEST.join(', ')}`); + logger.info(` Poll interval: ${POLL_INTERVAL_MS / 1000}s`); + logger.info(` Press Ctrl+C to stop\n`); + + // Log Redis connection details + const redisHost = process.env.ELASTICACHE_HOST || 'localhost'; + const redisPort = process.env.ELASTICACHE_PORT || '6379'; + + // For local development, disable TLS and cluster mode + const isLocal = !process.env.ELASTICACHE_HOST; + if (isLocal) { + logger.info('šŸ“” Using local Redis (TLS disabled)'); + // Set environment variables to disable TLS for local Redis + // getTlsConfiguration() checks these to determine TLS usage + process.env.RUNNING_LOCAL = 'true'; + process.env.LOCAL_CACHE = 'true'; + } + + logger.info(`šŸ“” Connecting to Redis:`); + logger.info(` Host: ${redisHost}`); + logger.info(` Port: ${redisPort}`); + logger.info(` Prefix: ${RedisClientPrefix.DLOB}`); + + const redisClient = new RedisClient({ + prefix: RedisClientPrefix.DLOB, + }); + + try { + await redisClient.connect(); + logger.info('āœ… Connected to Redis\n'); + } catch (error) { + logger.error('āŒ Failed to connect to Redis:', error.message); + logger.error('\nšŸ’” Troubleshooting:'); + logger.error(' 1. Make sure Redis is running locally:'); + logger.error(' $ redis-server'); + logger.error(' OR'); + logger.error(' $ docker run -d -p 6379:6379 redis:latest\n'); + logger.error(' 2. Check if Redis is accessible:'); + logger.error(' $ redis-cli ping'); + logger.error(' Expected: PONG\n'); + logger.error(' 3. Set environment variables if using non-default Redis:'); + logger.error(' $ export ELASTICACHE_HOST=your-redis-host'); + logger.error(' $ export ELASTICACHE_PORT=your-redis-port\n'); + process.exit(1); + } + + let iteration = 0; + + // Initial poll + iteration++; + await pollRedis(redisClient, iteration); + + // Set up periodic polling + const intervalId = setInterval(async () => { + iteration++; + await pollRedis(redisClient, iteration); + }, POLL_INTERVAL_MS); + + // Graceful shutdown + process.on('SIGINT', async () => { + logger.info('\n\nšŸ›‘ Shutting down...'); + clearInterval(intervalId); + await redisClient.disconnect(); + logger.info('āœ… Disconnected from Redis'); + logger.info( + `\nšŸ“Š Final Stats: Ran ${iteration} polling iterations over ${Math.floor( + (iteration * POLL_INTERVAL_MS) / 1000 + )}s` + ); + process.exit(0); + }); +}; + +main().catch((error) => { + logger.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/core/healthCheck.ts b/src/core/healthCheck.ts index 5676b31..c0643bc 100644 --- a/src/core/healthCheck.ts +++ b/src/core/healthCheck.ts @@ -12,11 +12,11 @@ export enum HEALTH_STATUS { const HEALTH_CHECK_CONFIG = { CHECK_INTERVAL_MS: 2000, // Maximum time allowed between slot updates - MAX_SLOT_STALENESS_MS: 5000, + MAX_SLOT_STALENESS_MS: parseInt(process.env.MAX_SLOT_STALENESS_MS || '5000'), // Minimum expected slot advancement rate. Sometimes usermap is used for slot source, so the slot rate // may be much lower than 2 per sec. 0.03 is 1 slot per 33s - MIN_SLOT_RATE: 0.03, -} as const; + MIN_SLOT_RATE: parseFloat(process.env.MIN_SLOT_RATE || '0.03'), +}; /** * Tracks the health state of the slot subscriber diff --git a/src/index.ts b/src/index.ts index bb36107..4f4943f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1050,7 +1050,11 @@ const main = async (): Promise => { } }); - const inputParams = createMarketBasedAuctionParams(auctionParamsInput, undefined, apiVersion); + const inputParams = createMarketBasedAuctionParams( + auctionParamsInput, + undefined, + apiVersion + ); const result = await mapToMarketOrderParams( inputParams, diff --git a/src/scripts/pollAuctionParams.ts b/src/scripts/pollAuctionParams.ts new file mode 100644 index 0000000..1a3dd6b --- /dev/null +++ b/src/scripts/pollAuctionParams.ts @@ -0,0 +1,152 @@ +/** + yarn run ts-node src/scripts/pollAuctionParams.ts [mainnet|staging] + */ + +import { logger } from '../utils/logger'; +import { setTimeout as sleep } from 'timers/promises'; +import { request } from 'undici'; +import * as fs from 'fs'; +import * as path from 'path'; + +const envArg = process.argv[2]; + +const ENV = envArg ?? 'mainnet'; +if (ENV !== 'mainnet' && ENV !== 'staging') { + throw new Error(`Invalid environment: ${ENV}`); +} +const VERSION = '&version=2'; + +// @ts-ignore +const TARGET_URL = `https://${ + ENV === 'staging' ? 'staging.' : '' +}dlob.drift.trade/auctionParams?assetType=base&marketType=perp&marketIndex=2&direction=long&maxLeverageSelected=false&maxLeverageOrderSize=18446744073709551615&amount=550000000&reduceOnly=false&auctionDuration=20&auctionStartPriceOffset=-0.1&auctionEndPriceOffset=0.1&auctionStartPriceOffsetFrom=mark&auctionEndPriceOffsetFrom=worst&slippageTolerance=dynamic&isOracleOrder=true&forceUpToSlippage=false${VERSION}`; + +// staging +// const TARGET_URL = 'https://staging.dlob.drift.trade/auctionParams?assetType=base&marketType=perp&marketIndex=0&direction=long&maxLeverageSelected=false&maxLeverageOrderSize=18446744073709551615&amount=2150000000&reduceOnly=false&auctionDuration=20&auctionStartPriceOffset=-0.1&auctionEndPriceOffset=0.1&auctionStartPriceOffsetFrom=mark&auctionEndPriceOffsetFrom=worst&slippageTolerance=dynamic&isOracleOrder=true&forceUpToSlippage=false'; + +const OUTPUT_CSV = process.env.OUTPUT_CSV || `auctionParams-${ENV}.csv`; +const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 10_000); + +const csvPath = path.resolve(process.cwd(), OUTPUT_CSV); + +function ensureCsvHeader(): void { + if (!fs.existsSync(csvPath)) { + const header = [ + 'timestamp_iso', + 'entryPrice', + 'bestPrice', + 'worstPrice', + 'oraclePrice', + 'markPrice', + 'priceImpact', + 'slippageTolerance', + 'auctionDuration', + 'auctionStartPrice', + 'auctionEndPrice', + 'oraclePriceOffset', + 'direction', + 'baseAssetAmount', + 'raw_json', + ].join(','); + fs.writeFileSync(csvPath, header + '\n'); + logger.info(`Created CSV with header at ${csvPath}`); + } +} + +function csvEscape(value: unknown): string { + if (value === null || value === undefined) return ''; + const str = String(value); + if (/[",\n]/.test(str)) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +async function fetchAuctionParams(): Promise { + const { statusCode, body } = await request(TARGET_URL, { method: 'GET' }); + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`HTTP ${statusCode}`); + } + const text = await body.text(); + try { + return JSON.parse(text); + } catch (e) { + throw new Error(`Invalid JSON: ${(e as Error).message}`); + } +} + +function appendRow(resp: any): void { + const nowIso = new Date().toISOString(); + + // Safely read expected fields if present + const data = resp?.data ?? {}; + const entryPrice = data?.entryPrice ?? ''; + const bestPrice = data?.bestPrice ?? ''; + const worstPrice = data?.worstPrice ?? ''; + const oraclePrice = data?.oraclePrice ?? ''; + const markPrice = data?.markPrice ?? ''; + const priceImpact = data?.priceImpact ?? ''; + const slippageTolerance = data?.slippageTolerance ?? ''; + + const params = data?.params ?? {}; + const auctionDuration = params?.auctionDuration ?? ''; + const auctionStartPrice = params?.auctionStartPrice ?? ''; + const auctionEndPrice = params?.auctionEndPrice ?? ''; + const oraclePriceOffset = params?.oraclePriceOffset ?? ''; + const direction = params?.direction ?? ''; + const baseAssetAmount = params?.baseAssetAmount ?? ''; + + const raw = JSON.stringify(resp); + + const row = [ + csvEscape(nowIso), + csvEscape(entryPrice), + csvEscape(bestPrice), + csvEscape(worstPrice), + csvEscape(oraclePrice), + csvEscape(markPrice), + csvEscape(priceImpact), + csvEscape(slippageTolerance), + csvEscape(auctionDuration), + csvEscape(auctionStartPrice), + csvEscape(auctionEndPrice), + csvEscape(oraclePriceOffset), + csvEscape(direction), + csvEscape(baseAssetAmount), + csvEscape(raw), + ].join(','); + + // Use appendFileSync for durability between iterations + fs.appendFileSync(csvPath, row + '\n'); +} + +async function main(): Promise { + ensureCsvHeader(); + logger.info( + `Polling ${TARGET_URL} every ${POLL_INTERVAL_MS}ms; writing to ${csvPath}` + ); + + while (true) { + try { + const start = Date.now(); + const resp = await fetchAuctionParams(); + logger.info( + `[${ENV} - ${VERSION}] Fetched auction params in ${ + Date.now() - start + }ms` + ); + appendRow(resp); + } catch (e) { + logger.error( + `Failed to fetch or write auction params: ${(e as Error).message}` + ); + } + + await sleep(POLL_INTERVAL_MS); + } +} + +main().catch((e) => { + logger.error(`Fatal error: ${e?.message ?? e}`); + process.exit(1); +});