Skip to content
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
60 changes: 58 additions & 2 deletions hasura/functions/leaderboard/get_leaderboard.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ DROP FUNCTION IF EXISTS public._leaderboard_win_rate(INT, TEXT, BOOLEAN);
DROP FUNCTION IF EXISTS public._leaderboard_hs_pct(INT, TEXT, BOOLEAN);
DROP FUNCTION IF EXISTS public._leaderboard_hltv_metric(TEXT, INT, TEXT, BOOLEAN, TEXT);
DROP FUNCTION IF EXISTS public._leaderboard_udr(INT, TEXT, BOOLEAN, TEXT);
DROP FUNCTION IF EXISTS public._leaderboard_external_rank(TEXT);

-- Belt-and-suspenders: sweep up any other historical get_leaderboard arity we
-- did not enumerate above (e.g. an even older 3-arg overload).
Expand All @@ -36,7 +37,8 @@ BEGIN
'_leaderboard_hs_pct',
'_leaderboard_awards',
'_leaderboard_hltv_metric',
'_leaderboard_udr'
'_leaderboard_udr',
'_leaderboard_external_rank'
)
LOOP
EXECUTE 'DROP FUNCTION ' || r.sig;
Expand Down Expand Up @@ -125,8 +127,14 @@ BEGIN
ELSIF _category = 'best_udr' THEN
RETURN QUERY SELECT * FROM _leaderboard_udr(_window_days, _match_type, _exclude_tournaments, _role, _season_id, _source);

ELSIF _category = 'faceit_elo' THEN
RETURN QUERY SELECT * FROM _leaderboard_external_rank('faceit');

ELSIF _category = 'premier_rank' THEN
RETURN QUERY SELECT * FROM _leaderboard_external_rank('premier');

ELSE
RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct, awards, best_rating, best_adr, best_kpr, best_kast, best_udr', _category;
RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct, awards, best_rating, best_adr, best_kpr, best_kast, best_udr, faceit_elo, premier_rank', _category;
END IF;
END;
$$;
Expand All @@ -135,6 +143,54 @@ $$;
-- ELO leaderboard
-- value = current ELO, secondary = ELO change, tertiary = win streak
-- ============================================================
-- FACEIT rating and Premier rank.
--
-- These are the only categories that do not come from our own matches: they are
-- snapshots we cache on players from FACEIT and from parsed Premier demos. They
-- therefore take no window, season, match type or source - a player's FACEIT
-- rating is what it is whether or not they played here this week, and filtering
-- one by "last 7 days" would empty the board rather than narrow it.
CREATE OR REPLACE FUNCTION public._leaderboard_external_rank(_rating TEXT)
RETURNS SETOF public.leaderboard_entries
LANGUAGE plpgsql STABLE
AS $$
BEGIN
IF _rating = 'faceit' THEN
RETURN QUERY
SELECT
p.steam_id::text,
p.name,
p.avatar_url,
p.country,
p.faceit_elo::float,
p.faceit_skill_level::float,
NULL::float,
0,
p.custom_avatar_url
FROM public.players p
WHERE p.faceit_elo IS NOT NULL
ORDER BY p.faceit_elo DESC, p.name ASC;
ELSE
RETURN QUERY
SELECT
p.steam_id::text,
p.name,
p.avatar_url,
p.country,
p.premier_rank::float,
NULL::float,
NULL::float,
0,
p.custom_avatar_url
FROM public.players p
-- The demo importer writes 0 for a player who has not placed this
-- season; ranked as a number that sorts as the worst rating in the game.
WHERE NULLIF(p.premier_rank, 0) IS NOT NULL
ORDER BY p.premier_rank DESC, p.name ASC;
END IF;
END;
$$;

CREATE OR REPLACE FUNCTION public._leaderboard_elo(
_window_days INT,
_match_type TEXT,
Expand Down
93 changes: 93 additions & 0 deletions src/faceit/faceit.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Logger } from "@nestjs/common";
import { FaceitService } from "./faceit.service";

// The leaderboard shows a cached FACEIT rating, so something has to keep the
// cache warm without hammering an API we do not own.
describe("FaceitService.refreshStaleRatings", () => {
let service: FaceitService;
let hasura: { query: jest.Mock; mutation: jest.Mock };
let stalePlayers: Array<{ steam_id: string }>;
let refreshed: string[];
let failFor: string[];

const build = (apiKey: string | null = "key") => {
hasura = {
query: jest.fn(async () => ({ players: stalePlayers })),
mutation: jest.fn(async () => ({})),
};

service = new FaceitService(
{ get: () => apiKey } as any,
{ has: jest.fn(), put: jest.fn() } as any,
hasura as any,
new Logger("FaceitTest"),
);

jest
.spyOn(service, "refreshPlayer")
.mockImplementation(async (steamId: string) => {
if (failFor.includes(steamId)) {
throw new Error("faceit is down");
}
refreshed.push(steamId);
return true;
});
};

beforeEach(() => {
stalePlayers = [
{ steam_id: "76561198000000001" },
{ steam_id: "76561198000000002" },
];
refreshed = [];
failFor = [];
build();
});

it("refreshes every stale player it is given", async () => {
const result = await service.refreshStaleRatings();

expect(refreshed.sort()).toEqual([
"76561198000000001",
"76561198000000002",
]);
expect(result.refreshed).toBe(2);
expect(result.failed).toBe(0);
});

it("only asks for players who already have a faceit account linked", async () => {
await service.refreshStaleRatings();

const where = hasura.query.mock.calls[0][0].players.__args.where;

expect(where.faceit_player_id._is_null).toBe(false);
});

it("asks for the least recently refreshed first, in a bounded batch", async () => {
await service.refreshStaleRatings();

const args = hasura.query.mock.calls[0][0].players.__args;

expect(args.order_by).toEqual([{ faceit_updated_at: "asc_nulls_first" }]);
expect(args.limit).toBeGreaterThan(0);
});

it("keeps going when one player fails", async () => {
failFor = ["76561198000000001"];

const result = await service.refreshStaleRatings();

expect(refreshed).toEqual(["76561198000000002"]);
expect(result.failed).toBe(1);
expect(result.refreshed).toBe(1);
});

it("does nothing at all without an api key", async () => {
build(null);

const result = await service.refreshStaleRatings();

expect(hasura.query).not.toHaveBeenCalled();
expect(result.refreshed).toBe(0);
});
});
62 changes: 62 additions & 0 deletions src/faceit/faceit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export class FaceitService {
private static readonly BASE_URL = "https://open.faceit.com/data/v4";
private static readonly REFRESH_INTERVAL_SECONDS = 60 * 60;
private static readonly NO_ACCOUNT_TTL_SECONDS = 12 * 60 * 60;
private static readonly STALE_REFRESH_LIMIT = 100;
private static readonly STALE_REFRESH_CONCURRENCY = 2;
private readonly apiKey: string;

constructor(
Expand Down Expand Up @@ -295,6 +297,66 @@ export class FaceitService {
}
}

// Keeps the ratings behind external_rank_leaderboard warm. Bounded and
// low-concurrency on purpose: this runs on a schedule against an API we do
// not own, and the leaderboard is a standings board, not a live readout.
public async refreshStaleRatings(): Promise<{
refreshed: number;
failed: number;
}> {
if (!this.isEnabled()) {
return { refreshed: 0, failed: 0 };
}

const { players } = await this.hasura.query({
players: {
__args: {
where: {
faceit_player_id: {
_is_null: false,
},
},
order_by: [{ faceit_updated_at: "asc_nulls_first" }],
limit: FaceitService.STALE_REFRESH_LIMIT,
},
steam_id: true,
},
});

let refreshed = 0;
let failed = 0;

const queue = [...(players ?? [])];

const worker = async () => {
while (queue.length > 0) {
const player = queue.shift();

if (!player) {
return;
}

try {
await this.refreshPlayer(player.steam_id);
refreshed++;
} catch (error) {
failed++;
this.logger.warn(
`faceit rating refresh failed for ${player.steam_id}: ${
(error as Error)?.message ?? String(error)
}`,
);
}
}
};

await Promise.all(
Array.from({ length: FaceitService.STALE_REFRESH_CONCURRENCY }, worker),
);

return { refreshed, failed };
}

public async refreshPlayer(steamId: string, force = false): Promise<boolean> {
if (!this.isEnabled()) {
return false;
Expand Down
22 changes: 22 additions & 0 deletions src/faceit/jobs/PollAllFaceitMatchHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,39 @@ import { WorkerHost } from "@nestjs/bullmq";
import { UseQueue } from "src/utilities/QueueProcessors";
import { FaceitQueues } from "../enums/FaceitQueues";
import { FaceitMatchImportService } from "../faceit-match-import.service";
import { FaceitService } from "../faceit.service";

@UseQueue("Faceit", FaceitQueues.PollAllFaceitMatchHistory)
export class PollAllFaceitMatchHistory extends WorkerHost {
constructor(
private readonly logger: Logger,
private readonly faceit: FaceitService,
private readonly faceitImport: FaceitMatchImportService,
) {
super();
}

async process(_job: Job): Promise<void> {
// Piggybacks on the poll rather than carrying its own schedule. Ratings
// feed the leaderboard, and refreshPlayer keeps its own per-player hourly
// lock, so a busier poll does not mean more calls to faceit.
try {
const { refreshed, failed } = await this.faceit.refreshStaleRatings();

if (refreshed || failed) {
this.logger.log(
`faceit ratings refreshed=${refreshed} failed=${failed}`,
);
}
} catch (error) {
// Never let the rating cache take the match import down with it.
this.logger.warn(
`faceit rating refresh pass failed: ${
(error as Error)?.message ?? String(error)
}`,
);
}

await this.faceitImport.pollAllActive();
}
}
Loading
Loading