mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
|
|
import { PayoutMode } from '../../types/payout-mode';
|
|
|
|
type HighScorePayoutMode = PayoutMode | 'all';
|
|
|
|
interface HighScoreCandidate {
|
|
bucketDate: string;
|
|
bucket: Date;
|
|
blockHeight: string;
|
|
bestSubmissionDifficulty: string;
|
|
}
|
|
|
|
interface ExactHighScore {
|
|
submissionDifficulty: string;
|
|
acceptedAt: Date | null;
|
|
blockHeight: string | null;
|
|
address: string | null;
|
|
clientName: string | null;
|
|
protocol: string | null;
|
|
}
|
|
|
|
interface HighScoreRecord {
|
|
scope: 'all_time' | 'daily';
|
|
payoutMode: HighScorePayoutMode;
|
|
bucketDate: string;
|
|
submissionDifficulty: string;
|
|
acceptedAt: Date | null;
|
|
bucket: Date | null;
|
|
blockHeight: string | null;
|
|
address: string | null;
|
|
clientName: string | null;
|
|
protocol: string | null;
|
|
}
|
|
|
|
export interface HighScoreRefreshResult {
|
|
processed: boolean;
|
|
reason?: 'locked';
|
|
updatedRows: number;
|
|
}
|
|
|
|
const DEFAULT_HIGH_SCORE_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
|
const DEFAULT_HIGH_SCORE_LOOKBACK_DAYS = 7;
|
|
const HIGH_SCORE_ADVISORY_LOCK = '1781309000';
|
|
const ALL_TIME_BUCKET_DATE = '1970-01-01';
|
|
|
|
@Injectable()
|
|
export class ShareHighScoreService implements OnModuleInit, OnModuleDestroy {
|
|
private timer: NodeJS.Timeout | null = null;
|
|
private activeRefresh: Promise<HighScoreRefreshResult> | null = null;
|
|
private readonly refreshIntervalMs = this.readPositiveInt(
|
|
'SHARE_HIGH_SCORE_REFRESH_INTERVAL_MS',
|
|
DEFAULT_HIGH_SCORE_REFRESH_INTERVAL_MS,
|
|
);
|
|
private readonly lookbackDays = this.readPositiveInt(
|
|
'SHARE_HIGH_SCORE_LOOKBACK_DAYS',
|
|
DEFAULT_HIGH_SCORE_LOOKBACK_DAYS,
|
|
);
|
|
|
|
constructor(private readonly dataSource: DataSource) { }
|
|
|
|
public onModuleInit(): void {
|
|
if (process.env.MASTER !== 'true' || process.env.API_ONLY === 'true') {
|
|
return;
|
|
}
|
|
|
|
this.timer = setInterval(() => {
|
|
if (this.activeRefresh != null) {
|
|
return;
|
|
}
|
|
|
|
this.activeRefresh = this.refreshHighScores()
|
|
.catch(error => {
|
|
console.error(`Accepted share high-score refresh failed: ${error.message}`);
|
|
return { processed: false, updatedRows: 0 };
|
|
})
|
|
.finally(() => {
|
|
this.activeRefresh = null;
|
|
});
|
|
}, this.refreshIntervalMs);
|
|
this.timer.unref?.();
|
|
|
|
setTimeout(() => {
|
|
if (this.activeRefresh == null) {
|
|
this.activeRefresh = this.refreshHighScores()
|
|
.catch(error => {
|
|
console.error(`Accepted share high-score refresh failed: ${error.message}`);
|
|
return { processed: false, updatedRows: 0 };
|
|
})
|
|
.finally(() => {
|
|
this.activeRefresh = null;
|
|
});
|
|
}
|
|
}, 30_000).unref?.();
|
|
}
|
|
|
|
public async onModuleDestroy(): Promise<void> {
|
|
if (this.timer != null) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
if (this.activeRefresh != null) {
|
|
await this.activeRefresh;
|
|
}
|
|
}
|
|
|
|
public async refreshHighScores(): Promise<HighScoreRefreshResult> {
|
|
return this.dataSource.transaction(async manager => {
|
|
const [lockRow] = await manager.query(`
|
|
SELECT pg_try_advisory_xact_lock($1::bigint) AS "locked"
|
|
`, [HIGH_SCORE_ADVISORY_LOCK]);
|
|
|
|
if (lockRow?.locked !== true) {
|
|
return { processed: false, reason: 'locked', updatedRows: 0 };
|
|
}
|
|
|
|
let updatedRows = 0;
|
|
for (const payoutMode of ['all', 'solo', 'pplns'] as HighScorePayoutMode[]) {
|
|
updatedRows += await this.refreshForPayoutMode(manager, payoutMode);
|
|
}
|
|
|
|
return { processed: true, updatedRows };
|
|
});
|
|
}
|
|
|
|
private async refreshForPayoutMode(manager: EntityManager, payoutMode: HighScorePayoutMode): Promise<number> {
|
|
const candidates = await this.loadDailyCandidates(manager, payoutMode);
|
|
let updatedRows = 0;
|
|
|
|
for (const candidate of candidates) {
|
|
const exact = await this.loadExactHighScore(manager, candidate, payoutMode);
|
|
const record = this.toRecord('daily', payoutMode, candidate.bucketDate, candidate, exact);
|
|
updatedRows += await this.upsertHighScore(manager, record);
|
|
updatedRows += await this.upsertHighScore(manager, {
|
|
...record,
|
|
scope: 'all_time',
|
|
bucketDate: ALL_TIME_BUCKET_DATE,
|
|
});
|
|
}
|
|
|
|
return updatedRows;
|
|
}
|
|
|
|
private async loadDailyCandidates(manager: EntityManager, payoutMode: HighScorePayoutMode): Promise<HighScoreCandidate[]> {
|
|
const params: unknown[] = [this.lookbackDays];
|
|
const modeFilter = payoutMode === 'all'
|
|
? ''
|
|
: `AND "payoutMode" = $${params.push(payoutMode)}`;
|
|
|
|
return manager.query(`
|
|
WITH candidate_rows AS (
|
|
SELECT
|
|
date_trunc('day', "bucket")::date AS "bucketDate",
|
|
"bucket",
|
|
"blockHeight",
|
|
"bestSubmissionDifficulty"
|
|
FROM "accepted_share_block_10m"
|
|
WHERE "bucket" >= NOW() - ($1::int * INTERVAL '1 day')
|
|
AND "bestSubmissionDifficulty" IS NOT NULL
|
|
AND "bestSubmissionDifficulty" > 0
|
|
${modeFilter}
|
|
)
|
|
SELECT DISTINCT ON ("bucketDate")
|
|
"bucketDate"::text AS "bucketDate",
|
|
"bucket",
|
|
"blockHeight"::text AS "blockHeight",
|
|
"bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty"
|
|
FROM candidate_rows
|
|
ORDER BY "bucketDate", "bestSubmissionDifficulty" DESC, "bucket" DESC
|
|
`, params);
|
|
}
|
|
|
|
private async loadExactHighScore(
|
|
manager: EntityManager,
|
|
candidate: HighScoreCandidate,
|
|
payoutMode: HighScorePayoutMode,
|
|
): Promise<ExactHighScore | null> {
|
|
const params: unknown[] = [candidate.bucket, candidate.blockHeight];
|
|
const modeFilter = payoutMode === 'all'
|
|
? ''
|
|
: `AND "payoutMode" = $${params.push(payoutMode)}`;
|
|
const rows = await manager.query(`
|
|
SELECT
|
|
"submissionDifficulty"::text AS "submissionDifficulty",
|
|
"acceptedAt",
|
|
"blockHeight"::text AS "blockHeight",
|
|
"address",
|
|
"clientName",
|
|
"protocol"
|
|
FROM "accepted_share_entity"
|
|
WHERE "acceptedAt" >= $1::timestamptz
|
|
AND "acceptedAt" < $1::timestamptz + INTERVAL '10 minutes'
|
|
AND "blockHeight" = $2::bigint
|
|
${modeFilter}
|
|
ORDER BY "submissionDifficulty" DESC, "acceptedAt" DESC
|
|
LIMIT 1
|
|
`, params);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
private toRecord(
|
|
scope: HighScoreRecord['scope'],
|
|
payoutMode: HighScorePayoutMode,
|
|
bucketDate: string,
|
|
candidate: HighScoreCandidate,
|
|
exact: ExactHighScore | null,
|
|
): HighScoreRecord {
|
|
return {
|
|
scope,
|
|
payoutMode,
|
|
bucketDate,
|
|
submissionDifficulty: exact?.submissionDifficulty ?? candidate.bestSubmissionDifficulty,
|
|
acceptedAt: exact?.acceptedAt ?? candidate.bucket,
|
|
bucket: candidate.bucket,
|
|
blockHeight: exact?.blockHeight ?? candidate.blockHeight,
|
|
address: exact?.address ?? null,
|
|
clientName: exact?.clientName ?? null,
|
|
protocol: exact?.protocol ?? null,
|
|
};
|
|
}
|
|
|
|
private async upsertHighScore(manager: EntityManager, record: HighScoreRecord): Promise<number> {
|
|
const rows = await manager.query(`
|
|
INSERT INTO "accepted_share_high_score" (
|
|
"scope",
|
|
"payoutMode",
|
|
"bucketDate",
|
|
"submissionDifficulty",
|
|
"acceptedAt",
|
|
"bucket",
|
|
"blockHeight",
|
|
"address",
|
|
"clientName",
|
|
"protocol"
|
|
) VALUES (
|
|
$1,
|
|
$2,
|
|
$3::date,
|
|
$4::numeric,
|
|
$5::timestamptz,
|
|
$6::timestamptz,
|
|
$7::bigint,
|
|
$8,
|
|
$9,
|
|
$10
|
|
)
|
|
ON CONFLICT ("scope", "payoutMode", "bucketDate")
|
|
DO UPDATE SET
|
|
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
|
|
"acceptedAt" = EXCLUDED."acceptedAt",
|
|
"bucket" = EXCLUDED."bucket",
|
|
"blockHeight" = EXCLUDED."blockHeight",
|
|
"address" = EXCLUDED."address",
|
|
"clientName" = EXCLUDED."clientName",
|
|
"protocol" = EXCLUDED."protocol",
|
|
"updatedAt" = NOW()
|
|
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
|
|
OR (
|
|
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
|
|
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
|
|
)
|
|
RETURNING 1
|
|
`, [
|
|
record.scope,
|
|
record.payoutMode,
|
|
record.bucketDate,
|
|
record.submissionDifficulty,
|
|
record.acceptedAt,
|
|
record.bucket,
|
|
record.blockHeight,
|
|
record.address,
|
|
record.clientName,
|
|
record.protocol,
|
|
]);
|
|
|
|
return rows.length;
|
|
}
|
|
|
|
private readPositiveInt(name: string, defaultValue: number): number {
|
|
const value = Number(process.env[name]);
|
|
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
|
}
|
|
}
|