diff --git a/README.md b/README.md index 30951e9..8781bdb 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ pnpm db:apply:cron-runs:testnet | `testnet` | [Validators API Testnet](https://validators-api-test.workers.dev) | Manual `wrangler deploy --env testnet` | | `testnet-preview` | [Validators API Testnet Preview](https://validators-api-test.workers.dev) | Manual deployment | -Each environment has its own D1 database, KV cache, and R2 blob. Sync runs hourly via Cloudflare cron triggers (see `server/tasks/sync/`). +Each environment has its own D1 database, KV cache, and R2 blob. Sync runs every 12 hours via Cloudflare cron triggers (see `server/tasks/sync/`). ### Deployment Migration diff --git a/app/app.vue b/app/app.vue index ee7fcbc..0abff0a 100644 --- a/app/app.vue +++ b/app/app.vue @@ -124,7 +124,7 @@ const currentEnvItem = { branch: gitBranch, network: nimiqNetwork, link: environ

- Note: Data synchronization is handled automatically by scheduled tasks that run hourly. Please wait for the next sync cycle or contact an administrator if the issue persists. + Note: Data synchronization is handled automatically by scheduled tasks that run every 12 hours. A score lag of up to 1 epoch can be expected between sync cycles.

diff --git a/nuxt.config.ts b/nuxt.config.ts index 5febaf2..7f99f74 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -161,5 +161,5 @@ export default defineNuxtConfig({ }, }, - compatibilityDate: '2025-03-21', + compatibilityDate: '2026-02-26', }) diff --git a/package.json b/package.json index 43bca48..75f5853 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:testnet:prod": "nr dev:packages && nuxt dev --remote=production --dotenv .env.testnet", "dev:local": "nr dev:packages && nuxt dev --dotenv .env.local", "dev:packages": "nr -C packages -r dev", - "build": "pnpm validators:bundle:generate && nr -r build && nuxt build", + "build": "pnpm validators:bundle:generate && nr -r build && NODE_OPTIONS=--max-old-space-size=4096 nuxt build", "generate": "nuxt generate", "preview": "npx wrangler --cwd .output dev", "postinstall": "nuxt prepare", diff --git a/server/api/[version]/status.get.ts b/server/api/[version]/status.get.ts index c60e49c..c55c154 100644 --- a/server/api/[version]/status.get.ts +++ b/server/api/[version]/status.get.ts @@ -43,14 +43,24 @@ export default defineCachedEventHandler(async () => { if (!headBlockOk) throw createError(errorHeadBlockNumber || 'No head block number') + const allowedScoreLagEpochs = 1 + const latestScoreEpoch = await getLatestScoreEpoch() + const scoreLagEpochs = getScoreLagEpochs({ + toEpoch: range.toEpoch, + latestScoreEpoch, + }) + const missingEpochs = await findMissingEpochs(range) - const missingScore = await isMissingScore(range) + const missingScore = await isScoreMissingWithLag(range, allowedScoreLagEpochs, latestScoreEpoch) return { range, validators: validatorsEpoch, missingEpochs, missingScore, + latestScoreEpoch, + scoreLagEpochs, + allowedScoreLagEpochs, blockchain: { network, headBlockNumber }, } }) diff --git a/server/utils/score-freshness.test.ts b/server/utils/score-freshness.test.ts new file mode 100644 index 0000000..ee5e71b --- /dev/null +++ b/server/utils/score-freshness.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { getScoreLagEpochs, isScoreLagMissing } from './score-freshness' + +describe('score freshness', () => { + it('considers score synced when latest score epoch equals current toEpoch', () => { + expect(getScoreLagEpochs({ toEpoch: 100, latestScoreEpoch: 100 })).toBe(0) + expect(isScoreLagMissing({ toEpoch: 100, latestScoreEpoch: 100, allowedLagEpochs: 1 })).toBe(false) + }) + + it('considers score synced when lag is exactly one epoch', () => { + expect(getScoreLagEpochs({ toEpoch: 100, latestScoreEpoch: 99 })).toBe(1) + expect(isScoreLagMissing({ toEpoch: 100, latestScoreEpoch: 99, allowedLagEpochs: 1 })).toBe(false) + }) + + it('considers score missing when lag is two epochs', () => { + expect(getScoreLagEpochs({ toEpoch: 100, latestScoreEpoch: 98 })).toBe(2) + expect(isScoreLagMissing({ toEpoch: 100, latestScoreEpoch: 98, allowedLagEpochs: 1 })).toBe(true) + }) + + it('considers score missing when no score exists', () => { + expect(getScoreLagEpochs({ toEpoch: 100, latestScoreEpoch: null })).toBeNull() + expect(isScoreLagMissing({ toEpoch: 100, latestScoreEpoch: null, allowedLagEpochs: 1 })).toBe(true) + }) +}) diff --git a/server/utils/score-freshness.ts b/server/utils/score-freshness.ts new file mode 100644 index 0000000..c7c5cdb --- /dev/null +++ b/server/utils/score-freshness.ts @@ -0,0 +1,19 @@ +export interface ScoreFreshnessParams { + toEpoch: number + latestScoreEpoch: number | null + allowedLagEpochs?: number +} + +export function getScoreLagEpochs({ toEpoch, latestScoreEpoch }: Pick): number | null { + if (latestScoreEpoch === null) + return null + + return Math.max(0, toEpoch - latestScoreEpoch) +} + +export function isScoreLagMissing({ toEpoch, latestScoreEpoch, allowedLagEpochs = 1 }: ScoreFreshnessParams): boolean { + if (latestScoreEpoch === null) + return true + + return (toEpoch - latestScoreEpoch) > allowedLagEpochs +} diff --git a/server/utils/scores.ts b/server/utils/scores.ts index 3e32a06..ca00e21 100644 --- a/server/utils/scores.ts +++ b/server/utils/scores.ts @@ -1,9 +1,10 @@ import type { Range, Result, ScoreParams } from 'nimiq-validator-trustscore/types' import type { NewScore } from './drizzle' -import { and, count, desc, eq, gte, lte, or } from 'drizzle-orm' +import { and, desc, eq, gte, lte, or, sql } from 'drizzle-orm' import { getRange } from 'nimiq-validator-trustscore/range' import { computeScore } from 'nimiq-validator-trustscore/score' import { activity } from '../db/schema' +import { isScoreLagMissing } from './score-freshness' import { getStoredValidatorsId } from './validators' interface CalculateScoreResult { @@ -117,12 +118,36 @@ export async function upsertScoresSnapshotEpoch(): Result return [true, undefined, { scores, range }] } -export async function isMissingScore(range: Range): Promise { - const scoreCount = await useDrizzle() - .select({ count: count(tables.scores.epochNumber) }) +export async function getLatestScoreEpoch(): Promise { + const latestScoreEpoch = await useDrizzle() + .select({ + latestScoreEpoch: sql`max(${tables.scores.epochNumber})`, + }) .from(tables.scores) - .where(eq(tables.scores.epochNumber, range.toEpoch)) .get() - .then(res => res?.count || 0) - return scoreCount === 0 + .then((res) => { + const value = res?.latestScoreEpoch + if (value === null || value === undefined) + return null + if (typeof value === 'number') + return value + + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + }) + + return latestScoreEpoch +} + +export async function isScoreMissingWithLag( + range: Range, + allowedLagEpochs = 1, + latestScoreEpoch?: number | null, +): Promise { + const epoch = latestScoreEpoch === undefined ? await getLatestScoreEpoch() : latestScoreEpoch + return isScoreLagMissing({ + toEpoch: range.toEpoch, + latestScoreEpoch: epoch, + allowedLagEpochs, + }) } diff --git a/wrangler.json b/wrangler.json index b871557..c134150 100644 --- a/wrangler.json +++ b/wrangler.json @@ -4,7 +4,7 @@ "main": "dist/server/index.mjs", "assets": { "directory": "dist/public" }, "account_id": "cf9baad7d68d7ee717f3339731e81dfb", - "compatibility_date": "2025-01-01", + "compatibility_date": "2026-02-26", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true,