Skip to content

Commit 8538043

Browse files
authored
Merge pull request #133 from nimiq/fix/cron-sync-bundled-import
fix(sync): bundle validators and fail cron
2 parents e017c56 + b5a7b82 commit 8538043

File tree

6 files changed

+600
-42
lines changed

6 files changed

+600
-42
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"dev:testnet:prod": "nr dev:packages && nuxt dev --remote=production --dotenv .env.testnet",
1414
"dev:local": "nr dev:packages && nuxt dev --dotenv .env.local",
1515
"dev:packages": "nr -C packages -r dev",
16-
"build": "nr -r build && nuxt build",
16+
"build": "pnpm validators:bundle:generate && nr -r build && nuxt build",
1717
"generate": "nuxt generate",
1818
"preview": "npx wrangler --cwd .output dev",
1919
"postinstall": "nuxt prepare",
@@ -28,7 +28,8 @@
2828
"db:apply:cron-runs:mainnet": "wrangler d1 execute validators-api-mainnet --remote --yes --command \"CREATE TABLE IF NOT EXISTS cron_runs (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, cron text NOT NULL, network text NOT NULL, git_branch text, started_at text NOT NULL, finished_at text, status text NOT NULL, error_message text, meta text);\" && wrangler d1 execute validators-api-mainnet --remote --yes --command \"CREATE INDEX IF NOT EXISTS idx_cron_runs_started_at ON cron_runs (started_at);\"",
2929
"db:apply:cron-runs:testnet": "wrangler d1 execute validators-api-testnet --remote --yes --command \"CREATE TABLE IF NOT EXISTS cron_runs (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, cron text NOT NULL, network text NOT NULL, git_branch text, started_at text NOT NULL, finished_at text, status text NOT NULL, error_message text, meta text);\" && wrangler d1 execute validators-api-testnet --remote --yes --command \"CREATE INDEX IF NOT EXISTS idx_cron_runs_started_at ON cron_runs (started_at);\"",
3030
"release": "bumpp -r && nr -r publish",
31-
"validate:json-files": "tsx scripts/validate-json-files.ts"
31+
"validate:json-files": "tsx scripts/validate-json-files.ts",
32+
"validators:bundle:generate": "tsx scripts/generate-validators-bundle.ts"
3233
},
3334
"dependencies": {
3435
"@nimiq/core": "catalog:",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { ValidatorJSON } from '../server/utils/schemas'
2+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3+
import { join, resolve } from 'node:path'
4+
import process from 'node:process'
5+
import { validatorSchema } from '../server/utils/schemas'
6+
7+
const NETWORKS = ['main-albatross', 'test-albatross'] as const
8+
type Network = (typeof NETWORKS)[number]
9+
10+
async function loadValidatorsForNetwork(rootDir: string, network: Network): Promise<ValidatorJSON[]> {
11+
const validatorsDir = join(rootDir, 'public', 'validators', network)
12+
const entries = await readdir(validatorsDir, { withFileTypes: true })
13+
const files = entries
14+
.filter(entry => entry.isFile())
15+
.map(entry => entry.name)
16+
.filter(name => name.endsWith('.json') && !name.endsWith('.example.json'))
17+
.sort((a, b) => a.localeCompare(b))
18+
19+
if (files.length === 0)
20+
throw new Error(`No validator files found in ${validatorsDir}`)
21+
22+
const validators: ValidatorJSON[] = []
23+
for (const file of files) {
24+
const filePath = join(validatorsDir, file)
25+
const raw = await readFile(filePath, 'utf8')
26+
27+
let parsedJson: unknown
28+
try {
29+
parsedJson = JSON.parse(raw)
30+
}
31+
catch (error) {
32+
throw new Error(`Invalid JSON at ${filePath}: ${String(error)}`)
33+
}
34+
35+
const parsedValidator = validatorSchema.safeParse(parsedJson)
36+
if (!parsedValidator.success)
37+
throw new Error(`Invalid validator JSON at ${network}/${file}: ${parsedValidator.error}`)
38+
39+
validators.push(parsedValidator.data)
40+
}
41+
42+
return validators
43+
}
44+
45+
function createManifestSource(validatorsByNetwork: Record<Network, ValidatorJSON[]>): string {
46+
const serialized = JSON.stringify(validatorsByNetwork, null, 2)
47+
return `/* eslint-disable style/quotes, style/quote-props, style/comma-dangle */
48+
import type { ValidatorJSON } from '../utils/schemas'
49+
50+
// This file is auto-generated by scripts/generate-validators-bundle.ts.
51+
// Do not edit manually.
52+
export const bundledValidatorsByNetwork = ${serialized} as unknown as Record<'main-albatross' | 'test-albatross', ValidatorJSON[]>
53+
`
54+
}
55+
56+
async function main() {
57+
const rootDir = resolve(process.cwd())
58+
const outputDir = join(rootDir, 'server', 'generated')
59+
const outputFile = join(outputDir, 'validators-bundle.generated.ts')
60+
61+
const entries = await Promise.all(NETWORKS.map(async network => [network, await loadValidatorsForNetwork(rootDir, network)] as const))
62+
const validatorsByNetwork = Object.fromEntries(entries) as Record<Network, ValidatorJSON[]>
63+
64+
await mkdir(outputDir, { recursive: true })
65+
await writeFile(outputFile, createManifestSource(validatorsByNetwork), 'utf8')
66+
console.log(`[validators-bundle] generated ${outputFile}`)
67+
}
68+
69+
main().catch((error) => {
70+
console.error('[validators-bundle] failed to generate bundle')
71+
console.error(error)
72+
process.exitCode = 1
73+
})

server/generated/validators-bundle.generated.ts

Lines changed: 482 additions & 0 deletions
Large diffs are not rendered by default.

server/tasks/cron/sync.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,26 @@ import { consola } from 'consola'
33
import { runTask } from 'nitropack/runtime'
44
import { eq, tables, useDrizzle } from '~~/server/utils/drizzle'
55

6-
const CRON_EXPRESSION = '0 */12 * * *'
6+
const CRON_EXPRESSION = '0 * * * *'
77
const TASKS: string[] = ['sync:epochs', 'sync:snapshot']
88

9+
interface FailedTask {
10+
name: string
11+
error: unknown
12+
}
13+
14+
class CronTaskFailureError extends Error {
15+
readonly failedTasks: FailedTask[]
16+
readonly results: Record<string, unknown>
17+
18+
constructor(message: string, failedTasks: FailedTask[], results: Record<string, unknown>) {
19+
super(message)
20+
this.name = 'CronTaskFailureError'
21+
this.failedTasks = failedTasks
22+
this.results = results
23+
}
24+
}
25+
926
function toErrorString(error: unknown) {
1027
if (error instanceof Error)
1128
return `${error.message}${error.stack ? `\n${error.stack}` : ''}`
@@ -58,6 +75,12 @@ export default defineTask({
5875
throw new Error(`${taskName} failed: ${result.error || 'unknown'}`)
5976
}
6077

78+
const failedTasks = Object.entries(results)
79+
.filter(([, r]) => (r as any)?.success === false)
80+
.map(([name, r]) => ({ name, error: (r as any)?.error ?? 'Unknown task failure' }))
81+
if (failedTasks.length > 0)
82+
throw new CronTaskFailureError(`Task failures: ${JSON.stringify(failedTasks)}`, failedTasks, results)
83+
6184
if (cronRunId) {
6285
try {
6386
await useDrizzle()
@@ -85,6 +108,16 @@ export default defineTask({
85108
catch (error) {
86109
const errorMessage = toErrorString(error)
87110
consola.error('[cron:sync] failed', error)
111+
const meta: Record<string, unknown> = {
112+
cron: CRON_EXPRESSION,
113+
network: nimiqNetwork,
114+
tasks: TASKS,
115+
}
116+
117+
if (error instanceof CronTaskFailureError) {
118+
meta.results = error.results
119+
meta.failedTasks = error.failedTasks
120+
}
88121

89122
if (cronRunId) {
90123
try {
@@ -94,11 +127,7 @@ export default defineTask({
94127
finishedAt: new Date().toISOString(),
95128
status: 'error',
96129
errorMessage,
97-
meta: {
98-
cron: CRON_EXPRESSION,
99-
network: nimiqNetwork,
100-
tasks: TASKS,
101-
},
130+
meta,
102131
})
103132
.where(eq(tables.cronRuns.id, cronRunId))
104133
.execute()

server/tasks/sync/snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default defineTask({
2020

2121
const [importSuccess, errorImport, importData] = await importValidatorsBundled(nimiqNetwork)
2222
if (!importSuccess || !importData) {
23-
const error = new Error(errorImport || 'Unable to import validators')
23+
const error = new Error(errorImport || 'Unable to import bundled validators')
2424
await sendSyncFailureNotification('snapshot', error)
2525
return { result: { success: false, error: errorImport } }
2626
}

server/utils/validators-bundle.ts

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,33 @@
11
import type { Result } from 'nimiq-validator-trustscore/types'
22
import type { ValidatorJSON } from './schemas'
3-
import { readdirSync } from 'node:fs'
4-
import { join } from 'node:path'
5-
import process from 'node:process'
3+
import { bundledValidatorsByNetwork } from '../generated/validators-bundle.generated'
64
import { validatorSchema } from './schemas'
75
import { storeValidator } from './validators'
86

97
interface ImportValidatorsBundledOptions {
108
shouldStore?: boolean
119
}
1210

13-
// In dev, Nitro's asset storage doesn't enumerate files via getKeys().
14-
// We list them from the filesystem and then read via storage.
15-
function getValidatorKeys(nimiqNetwork: string): string[] {
16-
if (import.meta.dev) {
17-
try {
18-
const dir = join(process.cwd(), 'server', 'assets', 'validators', nimiqNetwork)
19-
return readdirSync(dir)
20-
.filter(f => f.endsWith('.json') && !f.endsWith('.example.json'))
21-
.map(f => `${nimiqNetwork}:${f}`)
22-
}
23-
catch { return [] }
24-
}
25-
return []
26-
}
27-
2811
export async function importValidatorsBundled(nimiqNetwork?: string, options: ImportValidatorsBundledOptions = {}): Result<ValidatorJSON[]> {
2912
if (!nimiqNetwork)
3013
return [false, 'Nimiq network is required', undefined]
3114

3215
const { shouldStore = true } = options
33-
const storage = useStorage('assets:server:validators')
34-
35-
// Try storage getKeys first (works in production), fall back to filesystem (dev)
36-
let keys = await storage.getKeys(`${nimiqNetwork}`)
37-
if (keys.length === 0)
38-
keys = getValidatorKeys(nimiqNetwork)
16+
const bundledValidators = bundledValidatorsByNetwork[nimiqNetwork as keyof typeof bundledValidatorsByNetwork]
17+
if (!bundledValidators || bundledValidators.length === 0)
18+
return [false, `No bundled validators found for network: ${nimiqNetwork}`, undefined]
3919

4020
const validators: ValidatorJSON[] = []
41-
for (const key of keys) {
42-
if (!key.endsWith('.json') || key.endsWith('.example.json'))
43-
continue
44-
45-
const data = await storage.getItem(key)
21+
for (const data of bundledValidators) {
4622
const parsed = validatorSchema.safeParse(data)
4723
if (!parsed.success)
48-
return [false, `Invalid validator data at ${key}: ${parsed.error}`, undefined]
24+
return [false, `Invalid bundled validator data: ${parsed.error}`, undefined]
4925
validators.push(parsed.data)
5026
}
5127

5228
if (!shouldStore)
5329
return [true, undefined, validators]
5430

55-
if (validators.length === 0)
56-
return [false, `No bundled validators found for network: ${nimiqNetwork}`, undefined]
57-
5831
const results = await Promise.allSettled(validators.map(v => storeValidator(v.address, v, { upsert: true })))
5932
const failures = results.filter(r => r.status === 'rejected')
6033
if (failures.length > 0)

0 commit comments

Comments
 (0)