Skip to content

Commit 41779e0

Browse files
committed
fix: data sync + enable observability
1 parent df5fa8c commit 41779e0

File tree

10 files changed

+58
-199
lines changed

10 files changed

+58
-199
lines changed

packages/nimiq-validator-trustscore/src/fetcher.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@ export interface FetchActivityOptions extends Pick<BaseAlbatrossPolicyOptions, '
1111
* @default 5
1212
*/
1313
maxRetries?: number
14+
/**
15+
* Max concurrent RPC requests per batch. Workers allows ~6 concurrent outbound connections.
16+
* @default 120
17+
*/
18+
maxBatchSize?: number
1419
}
1520

1621
/**
1722
* For a given block number, fetches the validator slots assignation.
1823
* The block number MUST be an election block otherwise it will return an error result.
1924
*/
2025
export async function fetchActivity(epochIndex: number, options: FetchActivityOptions = {}): Result<EpochActivity> {
21-
const { maxRetries = 5, network = 'mainnet' } = options
26+
const { maxRetries = 5, network = 'mainnet', maxBatchSize: _maxBatchSize = 120 } = options
2227
// Epochs start at 1, but election block is the first block of the epoch
2328
const electionBlock = electionBlockOf(epochIndex, { network })!
2429
const [isBlockOk, errorBlockNumber, block] = await getBlockByNumber({ blockNumber: electionBlock, includeBody: false })
@@ -51,8 +56,8 @@ export async function fetchActivity(epochIndex: number, options: FetchActivityOp
5156
epochActivity[validator] = { address: validator, likelihood, missed: 0, rewarded: 0, dominanceRatioViaBalance, dominanceRatioViaSlots, balance, elected: true, stakers: 0 } as ElectedValidator
5257
}
5358

54-
const maxBatchSize = 120
55-
const minBatchSize = 10
59+
const maxBatchSize = _maxBatchSize
60+
const minBatchSize = Math.min(10, maxBatchSize)
5661
let batchSize = maxBatchSize
5762

5863
const createPromise = async (index: number, retryCount = 0): Promise<ResultSync<void>> => {

scripts/validate-json-files.ts

Lines changed: 14 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Result } from 'nimiq-validator-trustscore/types'
22
import type { ValidatorJSON } from '../server/utils/schemas'
3-
import { execSync } from 'node:child_process'
43
import process from 'node:process'
54
import { consola } from 'consola'
65
import { importValidators } from '../server/utils/json-files'
@@ -32,10 +31,9 @@ function formatValidationErrors(error: any): ValidationErrorDetails[] {
3231
})
3332
}
3433

35-
function logValidationErrors(errors: ValidationErrorDetails[], source: string, network: string) {
36-
consola.error(`\n❌ Validation failed for ${network} validators from ${source}:\n`)
34+
function logValidationErrors(errors: ValidationErrorDetails[], network: string) {
35+
consola.error(`\n❌ Validation failed for ${network} validators:\n`)
3736

38-
// Group errors by validator index
3937
const errorsByValidator = errors.reduce((acc, error) => {
4038
if (!acc[error.validatorIndex]) {
4139
acc[error.validatorIndex] = []
@@ -56,89 +54,47 @@ function logValidationErrors(errors: ValidationErrorDetails[], source: string, n
5654
consola.error(` Current value: ${valueStr}`)
5755
}
5856
})
59-
consola.error('') // Empty line between validators
57+
consola.error('')
6058
})
6159

6260
consola.info(`\n💡 Tips for fixing these errors:`)
6361

6462
const uniqueFields = [...new Set(errors.map(e => e.field))]
65-
if (uniqueFields.includes('address')) {
63+
if (uniqueFields.includes('address'))
6664
consola.info(` • Nimiq addresses should follow format: "NQ## #### #### #### #### #### #### #### ####"`)
67-
}
68-
if (uniqueFields.includes('logo')) {
65+
if (uniqueFields.includes('logo'))
6966
consola.info(` • Logos should be data URLs starting with "data:image/png,", "data:image/svg+xml," or "data:image/webp,"`)
70-
}
71-
if (uniqueFields.some(f => f.includes('contact'))) {
67+
if (uniqueFields.some(f => f.includes('contact')))
7268
consola.info(` • Social media handles should not include '@' symbol or should follow platform-specific format rules`)
73-
}
74-
if (uniqueFields.includes('website')) {
69+
if (uniqueFields.includes('website'))
7570
consola.info(` • Websites must be valid URLs starting with http:// or https://`)
76-
}
7771
}
7872

79-
async function validateValidators(source: 'filesystem' | 'github', nimiqNetwork: string, gitBranch: string): Result<ValidatorJSON[]> {
80-
const [importOk, errorReading, _validators] = await importValidators(source, { nimiqNetwork, shouldStore: false, gitBranch })
73+
async function validateValidators(nimiqNetwork: string): Result<ValidatorJSON[]> {
74+
const [importOk, errorReading, _validators] = await importValidators(nimiqNetwork)
8175
if (!importOk)
8276
return [false, errorReading, undefined]
8377

8478
const result = validatorsSchema.safeParse(_validators)
8579
if (!result.success) {
8680
const errors = formatValidationErrors(result.error)
87-
logValidationErrors(errors, source, nimiqNetwork)
81+
logValidationErrors(errors, nimiqNetwork)
8882
return [false, `Found ${errors.length} validation error(s)`, undefined]
8983
}
9084

9185
return [true, undefined, result.data]
9286
}
9387

94-
// get flag from --source. should be either 'filesystem' or 'github'
95-
const args = process.argv.slice(2)
96-
const source = args[0] || 'filesystem'
97-
if (source !== 'filesystem' && source !== 'github') {
98-
consola.error('Invalid source. Use either "filesystem" or "github".')
99-
process.exit(1)
100-
}
101-
consola.info(`Validating validators from ${source}...`)
102-
103-
// Try to get git branch, with fallbacks for GitHub Actions environment
104-
let gitBranch: string = ''
105-
106-
try {
107-
gitBranch = execSync('git branch --show-current', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
108-
}
109-
catch {
110-
// Fallback for GitHub Actions - git command might fail in detached HEAD state
111-
consola.warn('Unable to get current branch from git command, trying environment variables...')
112-
}
113-
114-
// If git command failed or returned empty, try GitHub Actions environment variables
115-
if (!gitBranch) {
116-
// For pull requests, use the head ref (source branch)
117-
gitBranch = process.env.GITHUB_HEAD_REF
118-
// For pushes, use the ref name
119-
|| process.env.GITHUB_REF_NAME
120-
// Last resort: use the commit SHA
121-
|| process.env.GITHUB_SHA
122-
|| ''
123-
}
124-
125-
if (!gitBranch) {
126-
consola.error('Unable to determine git branch or commit reference. Make sure you are in a git repository or running in a GitHub Actions environment.')
127-
process.exit(1)
128-
}
129-
130-
consola.info(`Using git reference: ${gitBranch}`)
131-
132-
const [okMain, errorMain, validatorsMain] = await validateValidators(source, 'main-albatross', gitBranch)
88+
const [okMain, errorMain, validatorsMain] = await validateValidators('main-albatross')
13389
if (!okMain) {
13490
consola.error(errorMain)
13591
process.exit(1)
13692
}
137-
consola.success(`✅ All ${validatorsMain.length} validators for main-albatross are valid in ${source}!`)
93+
consola.success(`✅ All ${validatorsMain.length} validators for main-albatross are valid!`)
13894

139-
const [okTest, errorTest, validatorsTest] = await validateValidators(source, 'test-albatross', gitBranch)
95+
const [okTest, errorTest, validatorsTest] = await validateValidators('test-albatross')
14096
if (!okTest) {
14197
consola.error(errorTest)
14298
process.exit(1)
14399
}
144-
consola.success(`✅ All ${validatorsTest.length} validators for test-albatross are valid in ${source}!`)
100+
consola.success(`✅ All ${validatorsTest.length} validators for test-albatross are valid!`)

server/assets/validators

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../public/validators

server/plugins/setup-database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default defineNitroPlugin(async () => {
1010
if (gitBranch !== 'dev')
1111
return
1212

13-
const [ok, error, validators] = await importValidators('filesystem', { nimiqNetwork, gitBranch })
13+
const [ok, error, validators] = await importValidatorsBundled(nimiqNetwork)
1414
if (!ok)
1515
throw new Error(`Error importing validators: ${error}`)
1616
consola.success(`${validators.length} validators imported successfully`)

server/tasks/cron/sync.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export default defineTask({
5252
for (const taskName of TASKS) {
5353
consola.info(`[cron:sync] running ${taskName}`)
5454
const res = await runTask(taskName, { payload: event.payload ?? {}, context: event.context ?? {} })
55-
results[taskName] = (res as any)?.result ?? res
55+
const result = (res as any)?.result ?? res
56+
results[taskName] = result
57+
if (result?.success === false)
58+
throw new Error(`${taskName} failed: ${result.error || 'unknown'}`)
5659
}
5760

5861
if (cronRunId) {

server/tasks/sync/epochs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default defineTask({
3939
break
4040

4141
const missingEpoch = missingEpochs.at(0)!
42-
const [activityOk, activityError, epochActivity] = await fetchActivity(missingEpoch)
42+
const [activityOk, activityError, epochActivity] = await fetchActivity(missingEpoch, { network: config.public.nimiqNetwork, maxBatchSize: import.meta.dev ? 120 : 6 })
4343
if (!activityOk || !epochActivity) {
4444
const error = new Error(activityError || 'Unable to fetch activity')
4545
await sendSyncFailureNotification('missing-epoch', error)

server/tasks/sync/snapshot.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,11 @@ export default defineTask({
1616
throw new Error('No Albatross RPC Node URL')
1717
initRpcClient({ url: rpcUrl })
1818

19-
const { nimiqNetwork, gitBranch } = config.public
19+
const { nimiqNetwork } = config.public
2020

21-
const [importSuccess, errorImport, importData] = import.meta.dev
22-
? await importValidators('filesystem', { nimiqNetwork, gitBranch })
23-
: await importValidatorsBundled(nimiqNetwork)
21+
const [importSuccess, errorImport, importData] = await importValidatorsBundled(nimiqNetwork)
2422
if (!importSuccess || !importData) {
25-
const error = new Error(errorImport || 'Unable to import from GitHub')
23+
const error = new Error(errorImport || 'Unable to import validators')
2624
await sendSyncFailureNotification('snapshot', error)
2725
return { result: { success: false, error: errorImport } }
2826
}

server/utils/json-files.ts

Lines changed: 14 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -2,133 +2,41 @@ import type { Result } from 'nimiq-validator-trustscore/types'
22
import type { ValidatorJSON } from './schemas'
33
import { readdir, readFile } from 'node:fs/promises'
44
import { extname } from 'node:path'
5-
import process from 'node:process'
6-
import { consola } from 'consola'
7-
import { $fetch } from 'ofetch'
85
import { join } from 'pathe'
96
import { validatorSchema } from './schemas'
7+
108
/**
119
* Import validators from a folder containing .json files.
12-
*
13-
* This function is expected to be used when initializing the database with validators, so it will throw
14-
* an error if the files are not valid and the program should stop.
10+
* Used by the validation script to check files on disk.
1511
*/
16-
export async function importValidatorsFromFiles(folderPath: string): Result<any[]> {
12+
export async function importValidators(nimiqNetwork: string): Result<ValidatorJSON[]> {
13+
if (!nimiqNetwork)
14+
return [false, 'Nimiq network is required', undefined]
15+
16+
const folderPath = `public/validators/${nimiqNetwork}`
1717
const allFiles = await readdir(folderPath)
1818
const files = allFiles
1919
.filter(f => extname(f) === '.json')
2020
.filter(f => !f.endsWith('.example.json'))
2121

22-
const rawValidators: any[] = []
22+
const validators: ValidatorJSON[] = []
2323
for (const file of files) {
2424
const filePath = join(folderPath, file)
2525
const fileContent = await readFile(filePath, 'utf8')
2626

27+
let raw: unknown
2728
try {
28-
rawValidators.push(JSON.parse(fileContent))
29+
raw = JSON.parse(fileContent)
2930
}
3031
catch (error) {
3132
return [false, `Invalid JSON in file: ${file}. Error: ${error}`, undefined]
3233
}
33-
}
34-
return [true, undefined, rawValidators]
35-
}
36-
37-
/**
38-
* Import validators from GitHub using the official GitHub REST API.
39-
*/
40-
async function importValidatorsFromGitHub(path: string, { gitBranch }: Pick<ImportValidatorsFromFilesOptions, 'gitBranch'>): Result<any[]> {
41-
// Default to main branch if not specified
42-
const branch = gitBranch || 'main'
43-
44-
// Check if running in a fork PR (GitHub Actions sets GITHUB_HEAD_REPOSITORY)
45-
const headRepo = process.env.GITHUB_HEAD_REPOSITORY // format: "owner/repo"
46-
const baseRepo = process.env.GITHUB_REPOSITORY || 'nimiq/validators-api'
47-
48-
// Use head repo if it's different from base (fork PR scenario)
49-
const [owner, repo] = (headRepo && headRepo !== baseRepo ? headRepo : baseRepo).split('/')
50-
51-
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
52-
53-
// 1. List directory contents
54-
let listing: Array<{
55-
name: string
56-
path: string
57-
type: 'file' | 'dir'
58-
download_url: string
59-
}>
60-
61-
const headers: HeadersInit = { 'User-Agent': 'request' }
62-
try {
63-
listing = await $fetch(apiUrl, { headers })
64-
}
65-
catch (e) {
66-
consola.warn(`Error listing validators folder on GitHub: ${e}`)
67-
return [false, `Could not list validators on GitHub ${apiUrl} | ${e}`, undefined]
68-
}
69-
70-
// 2. Filter only .json files (skip .example.json)
71-
const jsonFiles = listing.filter(file =>
72-
file.type === 'file'
73-
&& file.name.endsWith('.json')
74-
&& !file.name.endsWith('.example.json'),
75-
)
76-
77-
// 3. Fetch each file’s raw contents
78-
const rawContents = await Promise.all(jsonFiles.map(async (file) => {
79-
try {
80-
return await $fetch<string>(file.download_url, { headers })
81-
}
82-
catch (e) {
83-
consola.warn(`Failed to download ${file.path}: ${e}`)
84-
return [false, `Failed to download ${file.path}: ${e}`, undefined]
85-
}
86-
}))
87-
88-
// 4. Parse JSON and return
89-
const parsed = rawContents.filter((c): c is string => Boolean(c)).map(c => JSON.parse(c!))
90-
91-
return [true, undefined, parsed]
92-
}
93-
94-
interface ImportValidatorsFromFilesOptions {
95-
nimiqNetwork?: string
96-
gitBranch?: string
97-
shouldStore?: boolean
98-
}
9934

100-
/**
101-
* Import validators from either the filesystem or GitHub, then validate & store.
102-
*/
103-
export async function importValidators(source: 'filesystem' | 'github', options: ImportValidatorsFromFilesOptions = {}): Result<ValidatorJSON[]> {
104-
const { nimiqNetwork, shouldStore = true, gitBranch } = options
105-
if (!nimiqNetwork)
106-
return [false, 'Nimiq network is required', undefined]
107-
108-
const path = `public/validators/${nimiqNetwork}`
109-
110-
const [ok, readError, data] = source === 'filesystem'
111-
? await importValidatorsFromFiles(path)
112-
: await importValidatorsFromGitHub(path, { gitBranch })
113-
114-
if (!ok)
115-
return [false, readError, undefined]
116-
117-
const validators: ValidatorJSON[] = []
118-
for (const validator of data) {
119-
const { data, success, error } = validatorSchema.safeParse(validator)
120-
if (!success)
121-
return [false, `Invalid validator ${validator.name}(${validator.address}) data: ${error || 'Unknown error'}. ${JSON.stringify({ path, gitBranch, source })}`, undefined]
122-
validators.push(data)
35+
const parsed = validatorSchema.safeParse(raw)
36+
if (!parsed.success)
37+
return [false, `Invalid validator ${file}: ${parsed.error}`, undefined]
38+
validators.push(parsed.data)
12339
}
12440

125-
if (!shouldStore)
126-
return [true, undefined, validators]
127-
128-
const results = await Promise.allSettled(validators.map(v => storeValidator(v.address, v, { upsert: true })))
129-
const failures = results.filter(r => r.status === 'rejected')
130-
if (failures.length > 0)
131-
return [false, `Errors importing validators: ${failures.map((f: any) => f.reason).join(', ')}`, undefined]
132-
13341
return [true, undefined, validators]
13442
}

0 commit comments

Comments
 (0)