Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support from/to time range query in cycle signers endpoint #37

Merged
merged 2 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@stacks/transactions": "^6.1.0",
"@types/node": "^20.16.1",
"bignumber.js": "^9.1.2",
"date-fns": "^4.1.0",
"env-schema": "^5.1.0",
"evt": "^1.11.2",
"fastify": "4.15.0",
Expand Down
8 changes: 8 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class InvalidRequestError extends Error {
status: number;
constructor(msg: string, status: number = 400) {
super(msg);
this.name = this.constructor.name;
this.status = status;
}

Check warning on line 7 in src/api/errors.ts

View check run for this annotation

Codecov / codecov/patch

src/api/errors.ts#L4-L7

Added lines #L4 - L7 were not covered by tests
}
35 changes: 30 additions & 5 deletions src/api/routes/cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { FastifyPluginCallback } from 'fastify';
import { Server } from 'http';
import { CycleSignerResponseSchema, CycleSignersResponseSchema } from '../schemas';
import { parseTime } from '../../helpers';
import { InvalidRequestError } from '../errors';

export const CycleRoutes: FastifyPluginCallback<
Record<never, never>,
Expand All @@ -20,6 +22,12 @@
cycle_number: Type.Integer({ description: 'PoX cycle number' }),
}),
querystring: Type.Object({
from: Type.Optional(
Type.String({ description: 'Start of time range (e.g., now-2h or ISO timestamp)' })
),
to: Type.Optional(
Type.String({ description: 'End of time range (e.g., now or ISO timestamp)' })
),
limit: Type.Integer({
description: 'Number of results to return (default: 100)',
default: 100,
Expand All @@ -35,12 +43,29 @@
},
},
async (request, reply) => {
const { from, to, limit, offset } = request.query;

const fromDate = from ? parseTime(from) : null;
const toDate = to ? parseTime(to) : null;
if (from && !fromDate) {
throw new InvalidRequestError('`from` parameter has an invalid format.');
}

Check warning on line 52 in src/api/routes/cycle.ts

View check run for this annotation

Codecov / codecov/patch

src/api/routes/cycle.ts#L51-L52

Added lines #L51 - L52 were not covered by tests
if (to && !toDate) {
throw new InvalidRequestError('`to` parameter has an invalid format.');
}

Check warning on line 55 in src/api/routes/cycle.ts

View check run for this annotation

Codecov / codecov/patch

src/api/routes/cycle.ts#L54-L55

Added lines #L54 - L55 were not covered by tests
if (fromDate && toDate && fromDate > toDate) {
throw new InvalidRequestError('`from` parameter must be earlier than `to` parameter.');
}

Check warning on line 58 in src/api/routes/cycle.ts

View check run for this annotation

Codecov / codecov/patch

src/api/routes/cycle.ts#L57-L58

Added lines #L57 - L58 were not covered by tests

const result = await fastify.db.sqlTransaction(async sql => {
const results = await fastify.db.getSignersForCycle(
request.params.cycle_number,
request.query.limit,
request.query.offset
);
const results = await fastify.db.getSignersForCycle({
sql,
cycleNumber: request.params.cycle_number,
fromDate: fromDate ?? undefined,
toDate: toDate ?? undefined,
limit,
offset,
});

const formatted = results.map(result => {
return {
Expand Down
39 changes: 39 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { addAbortListener } from 'node:events';
import { parseISO, sub, isValid, Duration } from 'date-fns';

export const isDevEnv = process.env.NODE_ENV === 'development';
export const isTestEnv = process.env.NODE_ENV === 'test';
Expand Down Expand Up @@ -37,3 +38,41 @@
}
});
}

/**
* Helper function to parse relative or ISO time strings
* @param timeStr - Examples: 'now', 'now-1d', 'now-3h', '2024-11-01T15:16:53.891Z'
* @returns Date object or null if parsing failed
*/
export function parseTime(timeStr: string): Date | null {
if (timeStr === 'now') {
return new Date();
}

if (timeStr.startsWith('now-')) {
const relativeMatch = timeStr.match(/now-(\d+)(s|mo|m|h|d|w|y)/i);
if (relativeMatch) {
const [, amount, unit] = relativeMatch;
const unitsMap: Record<string, keyof Duration> = {
s: 'seconds',
m: 'minutes',
h: 'hours',
d: 'days',
w: 'weeks',
mo: 'months',
y: 'years',
};
if (unitsMap[unit]) {
return sub(new Date(), { [unitsMap[unit]]: parseInt(amount) });
}
}
} else {
const date = parseISO(timeStr);
if (isValid(date)) {
return date;
}
}

// Return null if parsing failed
return null;
}

Check warning on line 78 in src/helpers.ts

View check run for this annotation

Codecov / codecov/patch

src/helpers.ts#L76-L78

Added lines #L76 - L78 were not covered by tests
26 changes: 23 additions & 3 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,21 @@ export class PgStore extends BasePgStore {
return result;
}

async getSignersForCycle(cycleNumber: number, limit: number, offset: number) {
async getSignersForCycle({
sql,
cycleNumber,
limit,
offset,
fromDate,
toDate,
}: {
sql: PgSqlClient;
cycleNumber: number;
limit: number;
offset: number;
fromDate?: Date;
toDate?: Date;
}) {
// TODO: add pagination
// TODO: joins against the block_signer_signatures table to determine mined_blocks_* values

Expand All @@ -270,7 +284,10 @@ export class PgStore extends BasePgStore {
// * Number of block_proposal entries that are missing an associated block_response entry.
// * The average time duration between block_proposal.received_at and block_response.received_at.

const dbRewardSetSigners = await this.sql<
const fromFilter = fromDate ? sql`AND bp.received_at >= ${fromDate}` : sql``;
const toFilter = toDate ? sql`AND bp.received_at < ${toDate}` : sql``;

const dbRewardSetSigners = await sql<
{
signer_key: string;
weight: number;
Expand Down Expand Up @@ -300,7 +317,10 @@ export class PgStore extends BasePgStore {
bp.block_height,
bp.received_at AS proposal_received_at
FROM block_proposals bp
WHERE bp.reward_cycle = ${cycleNumber}
WHERE
bp.reward_cycle = ${cycleNumber}
${fromFilter}
${toFilter}
),
response_data AS (
-- Select responses associated with the proposals from the given cycle
Expand Down
77 changes: 77 additions & 0 deletions tests/db/endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as fs from 'node:fs';
import * as readline from 'node:readline/promises';
import * as assert from 'node:assert';
import * as zlib from 'node:zlib';
import * as supertest from 'supertest';
import { FastifyInstance } from 'fastify';
import * as dateFns from 'date-fns';
import { StacksPayload } from '@hirosystems/chainhook-client';
import { buildApiServer } from '../../src/api/init';
import { PgStore } from '../../src/pg/pg-store';
Expand Down Expand Up @@ -85,6 +87,10 @@ describe('Postgres ingestion tests', () => {
.expect(200);
const body: BlocksResponse = responseTest.body;

const firstBlockTime = new Date(body.results[0].block_time * 1000).toISOString();
const lastBlockTime = new Date((body.results.at(-1)?.block_time ?? 0) * 1000).toISOString();
console.log(`First block time: ${firstBlockTime}, Last block time: ${lastBlockTime}`);

// block 112274 has all signer states (missing, rejected, accepted, accepted_excluded)
const testBlock = body.results.find(r => r.block_height === 112274);
assert.ok(testBlock);
Expand Down Expand Up @@ -141,6 +147,77 @@ describe('Postgres ingestion tests', () => {
expect(testSigner).toEqual(expectedSignerData);
});

test('get signers for cycle with time range', async () => {
const blocksResponse = await supertest(apiServer.server)
.get('/signer-metrics/v1/blocks?limit=20')
.expect(200);
const { results: allBlocks } = blocksResponse.body as BlocksResponse;
const blocks = allBlocks.filter(b => b.signer_data);

const latestBlockTime = new Date(blocks[0].signer_data!.block_proposal_time_ms);
const secondLatestBlockTime = new Date(blocks[1].signer_data!.block_proposal_time_ms);
const oldestBlock = new Date(blocks.at(-1)!.signer_data!.block_proposal_time_ms);

// Get a range that includes the first two blocks
const from1 = dateFns.subSeconds(secondLatestBlockTime, 1);
const to1 = dateFns.addSeconds(latestBlockTime, 1);

const signersResp1 = await supertest(apiServer.server)
.get(
`/signer-metrics/v1/cycles/72/signers?from=${from1.toISOString()}&to=${to1.toISOString()}`
)
.expect(200);
const signersBody1: CycleSignersResponse = signersResp1.body;
const testSignerKey1 = '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504';
const testSigner1 = signersBody1.results.find(r => r.signer_key === testSignerKey1);
const expectedSignerData1: CycleSigner = {
signer_key: '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504',
weight: 38,
weight_percentage: 76,
stacked_amount: '250000000000000',
stacked_amount_percent: 74.127,
stacked_amount_rank: 1,
proposals_accepted_count: 1,
proposals_rejected_count: 0,
proposals_missed_count: 1,
average_response_time_ms: 28515,
};
expect(testSigner1).toEqual(expectedSignerData1);

// number of seconds between now and the second latest block
let latestBlockSecondsAgo = dateFns.differenceInSeconds(new Date(), secondLatestBlockTime);
latestBlockSecondsAgo += 10; // add a few seconds to account for test execution time
const signersResp2 = await supertest(apiServer.server)
.get(`/signer-metrics/v1/cycles/72/signers?from=now-${latestBlockSecondsAgo}s&to=now`)
.expect(200);
const signersBody2: CycleSignersResponse = signersResp2.body;
const testSigner2 = signersBody2.results.find(r => r.signer_key === testSignerKey1);
// should return data for the last 2 blocks
expect(testSigner2).toEqual(expectedSignerData1);

const oldestBlockSecondsAgo = dateFns.differenceInSeconds(new Date(), oldestBlock);
const signersResp3 = await supertest(apiServer.server)
.get(
`/signer-metrics/v1/cycles/72/signers?from=${oldestBlock.toISOString()}&to=now-${oldestBlockSecondsAgo}s`
)
.expect(200);
const signersBody3: CycleSignersResponse = signersResp3.body;
const testSigner3 = signersBody3.results.find(r => r.signer_key === testSignerKey1);
// should return data for the oldest block
expect(testSigner3).toEqual({
signer_key: '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504',
weight: 38,
weight_percentage: 76,
stacked_amount: '250000000000000',
stacked_amount_percent: 74.127,
stacked_amount_rank: 1,
proposals_accepted_count: 1,
proposals_rejected_count: 0,
proposals_missed_count: 0,
average_response_time_ms: 29020,
});
});

test('get signer for cycle', async () => {
// this signer has all states (missing, rejected, accepted)
const testSignerKey = '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504';
Expand Down
Loading