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
65 changes: 64 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
worker: ${{ steps.filter.outputs.worker }}
dashboard: ${{ steps.filter.outputs.dashboard }}
collection: ${{ steps.filter.outputs.collection }}
website: ${{ steps.filter.outputs.website }}
Expand All @@ -25,6 +26,10 @@ jobs:
id: filter
with:
filters: |
worker:
- 'apps/worker/**'
- 'packages/db/**'
- '.github/workflows/ci.yml'
server:
- 'apps/server/**'
- 'packages/db/**'
Expand Down Expand Up @@ -147,6 +152,51 @@ jobs:
with:
path: apps/server/test-report/

worker:
name: Worker
needs: changes
if: ${{ needs.changes.outputs.worker == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: lovat_test
POSTGRES_PASSWORD: lovat_test
POSTGRES_DB: lovat_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U lovat_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: apps/worker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.20.0
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: npm --prefix ../../packages/db ci --include=dev
- run: npm --prefix ../../packages/db run build
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun run typecheck
- name: Test worker against disposable PostgreSQL
env:
DATABASE_URL: postgresql://lovat_test:lovat_test@127.0.0.1:5432/lovat_test
LOVAT_DB_TEST: "1"
run: |
npm --prefix ../../packages/db run db:deploy
bun run test
- name: Build worker deployment image
working-directory: .
run: docker build -f apps/worker/Dockerfile -t lovat-worker:ci .

test-report:
name: Publish test report
needs: [server, gate]
Expand Down Expand Up @@ -270,12 +320,24 @@ jobs:
gate:
name: Monorepo CI
if: ${{ always() }}
needs: [changes, structure, server, collection, dashboard-format, dashboard, website, learn]
needs:
[
changes,
structure,
server,
worker,
collection,
dashboard-format,
dashboard,
website,
learn,
]
runs-on: ubuntu-latest
env:
CHANGES_RESULT: ${{ needs.changes.result }}
STRUCTURE_RESULT: ${{ needs.structure.result }}
SERVER_RESULT: ${{ needs.server.result }}
WORKER_RESULT: ${{ needs.worker.result }}
COLLECTION_RESULT: ${{ needs.collection.result }}
WEBSITE_RESULT: ${{ needs.website.result }}
LEARN_RESULT: ${{ needs.learn.result }}
Expand All @@ -288,6 +350,7 @@ jobs:
"$CHANGES_RESULT" \
"$STRUCTURE_RESULT" \
"$SERVER_RESULT" \
"$WORKER_RESULT" \
"$COLLECTION_RESULT" \
"$DASHBOARD_FORMAT_RESULT" \
"$DASHBOARD_RESULT" \
Expand Down
275 changes: 14 additions & 261 deletions apps/server/src/handler/manager/addTournamentMatches.ts
Original file line number Diff line number Diff line change
@@ -1,274 +1,27 @@
import prismaClient from "../../prismaClient.js";
import z from "zod";
import axios from "axios";
import type { AxiosResponse } from "axios";

interface TbaAlliance {
team_keys: string[];
}

interface TbaMatch {
actual_time: number | null;
time: number | null;
comp_level: string;
match_number: number;
key: string;
alliances: {
red: TbaAlliance;
blue: TbaAlliance;
};
}

type TbaMatchesResponse = TbaMatch[];

// Keep existing callers, but let the worker be the only official match writer.
export const addTournamentMatches = async (
tournamentKey: string,
): Promise<void> => {
try {
if (tournamentKey === undefined) {
throw "tournament key is undefined";
}

if (!tournamentKey.startsWith("2026")) {
return;
}

const url = "https://www.thebluealliance.com/api/v3";
const tournamentRow = await prismaClient.tournament.findUnique({
where: {
key: tournamentKey,
},
});

if (tournamentRow === null) {
throw "tournament not found when trying to insert tournament matches";
}
if (typeof tournamentKey !== "string" || !tournamentKey.trim()) return;

const eventResponse = await fetch(`${url}/event/${tournamentKey}`, {
headers: { "X-TBA-Auth-Key": process.env.TBA_KEY ?? "" },
const tournament = await prismaClient.tournament.findUnique({
where: { key: tournamentKey },
select: { key: true },
});

const json: unknown = await eventResponse.json();
if (!tournament) return;

console.log(JSON.stringify(json, null, 2));

const event = z
.object({
remap_teams: z.record(z.string(), z.string()).nullish(),
})
.passthrough()
.parse(json);

const remap_teams = event.remap_teams ?? {};
let matchesResponse: AxiosResponse<TbaMatchesResponse>;
try {
matchesResponse = await axios.get<TbaMatchesResponse>(
`${url}/event/${tournamentKey}/matches`,
{
headers: {
"X-TBA-Auth-Key": process.env.TBA_KEY,
"If-None-Match": tournamentRow.latestFetchETag ?? "",
},
},
);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 304) {
return;
} else {
throw error;
}
}

await prismaClient.tournament.update({
where: {
key: tournamentKey,
},
data: {
latestFetchETag: matchesResponse.headers.etag,
},
await prismaClient.importJob.upsert({
where: { kind_targetKey: { kind: "matches", targetKey: tournamentKey } },
// Preserve the worker's active lease, refresh cadence and retry backoff.
update: {},
create: { kind: "matches", targetKey: tournamentKey, runAt: new Date() },
Comment on lines +17 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve missing-match report uploads during async refresh

When a scouting client submits a report before its TeamMatchData row has been stored, both addScoutReport.ts and addScoutReportDashboard.ts await this function and immediately repeat the lookup, assuming the await means the import finished. This implementation only inserts a job and returns, so the second lookup normally still misses and rejects the report with 404 even when the match exists in TBA; an already-scheduled job may not even run immediately because update: {} preserves its future runAt. The async handoff needs a pending/retry flow in these write endpoints, or it must otherwise preserve the prior guarantee that the row exists before returning.

Useful? React with 👍 / 👎.

});

// playoff formats come from tba's event.playoff_type
// Double Elim 8 team is 10
// Double Elim 4 team is 11
// as per https://github.com/the-blue-alliance/the-blue-alliance/blob/main/src/backend/common/consts/playoff_type.py

const eightTeamDoubleElimPlayoffMatchOrder = new Map<string, number>([
["sf1m1", 1],
["sf2m1", 2],
["sf3m1", 3],
["sf4m1", 4],
["sf5m1", 5],
["sf6m1", 6],
["sf7m1", 7],
["sf8m1", 8],
["sf9m1", 9],
["sf10m1", 10],
["sf11m1", 11],
["sf12m1", 12],
["sf13m1", 13],
["f1m1", 14],
["f1m2", 15],
]);

const fourTeamDoubleElimPlayoffMatchOrder = new Map<string, number>([
["sf1m1", 1],
["sf2m1", 2],
["sf3m1", 3],
["sf4m1", 4],
["sf5m1", 5],
["f1m1", 6],
["f1m2", 7],
]);

const playoffMatchOrder =
event.playoff_type === 10
? eightTeamDoubleElimPlayoffMatchOrder
: fourTeamDoubleElimPlayoffMatchOrder;

// For each match in the tournament
matchesResponse.data.sort(
(a: TbaMatch, b: TbaMatch) =>
(a.actual_time ?? a.time ?? 0) - (b.actual_time ?? b.time ?? 0),
);

for (const match of matchesResponse.data) {
if (match.comp_level == "qm") {
//all teams in the match
const teams = [
...match.alliances.red.team_keys,
...match.alliances.blue.team_keys,
];
let matchesString = ``;
//make matches with trailing _0, _1, _2 etc
for (let k = 0; k < teams.length; k++) {
matchesString =
matchesString +
`('${tournamentKey}_qm${match.match_number}_${k}', '${tournamentKey}', ${match.match_number}, '${teams[k]}', '${match.comp_level}'), `;
const currMatchKey = `${tournamentKey}_qm${match.match_number}_${k}`;

const fakeTeamKey = teams[k]; // The one TBA sends you which is potentially "fake", like frc6418B
const mapEntry = Object.entries(remap_teams).find(
(v) => v[1] === fakeTeamKey,
);
const realTeamKey = mapEntry ? mapEntry[0] : fakeTeamKey;
const currTeam = Number(realTeamKey.substring(3));

const params = z
.object({
matchNumber: z.number(),
tournamentKey: z.string(),
key: z.string(),
teamNumber: z.number(),
})
.safeParse({
key: currMatchKey,
tournamentKey: tournamentKey,
matchNumber: match.match_number,
teamNumber: currTeam,
});

if (!params.success) {
throw params;
}

//cant use currMatch key bc theres an issue with the enum
await prismaClient.teamMatchData.upsert({
where: {
key: currMatchKey,
},
update: {
tournamentKey: params.data.tournamentKey,
matchNumber: params.data.matchNumber,
teamNumber: params.data.teamNumber,
matchType: "QUALIFICATION",
},
create: {
key: params.data.key,
tournamentKey: params.data.tournamentKey,
matchNumber: params.data.matchNumber,
teamNumber: params.data.teamNumber,
matchType: "QUALIFICATION",
},
});
}
} else {
const teams = [
...match.alliances.red.team_keys,
...match.alliances.blue.team_keys,
];

if (teams.length !== 6) {
continue;
}

const mappedTeams: number[] = [];
let allTeamsKnown = true;
for (const teamKey of teams) {
const mapEntry = Object.entries(remap_teams).find(
(v) => v[1] === teamKey,
);
const realTeamKey = mapEntry ? mapEntry[0] : teamKey;
const teamNumber = Number(realTeamKey.substring(3));
if (!Number.isFinite(teamNumber) || teamNumber <= 0) {
allTeamsKnown = false;
break;
}
mappedTeams.push(teamNumber);
}

if (!allTeamsKnown) {
continue;
}

const matchSuffix = match.key.split("_")[1] ?? "";
const matchNumber = playoffMatchOrder.get(matchSuffix);
if (!matchNumber) {
continue;
}

for (let k = 0; k < 6; k++) {
const currTeam = mappedTeams[k];

const currMatchKey = `${tournamentKey}_em${matchNumber}_${k}`;

const params = z
.object({
matchNumber: z.number(),
tournamentKey: z.string(),
key: z.string(),
teamNumber: z.number(),
})
.parse({
key: currMatchKey,
tournamentKey: tournamentKey,
matchNumber: matchNumber,
teamNumber: currTeam,
});

//cant use currMatch key bc theres an issue with the enum
await prismaClient.teamMatchData.upsert({
where: {
key: currMatchKey,
},
update: {
tournamentKey: params.tournamentKey,
matchNumber: params.matchNumber,
teamNumber: params.teamNumber,
matchType: "ELIMINATION",
},
create: {
key: params.key,
tournamentKey: params.tournamentKey,
matchNumber: params.matchNumber,
teamNumber: params.teamNumber,
matchType: "ELIMINATION",
},
});
}
}
}
} catch (error) {
console.log(error);
} catch {
// A failed enqueue must not prevent callers from reading stored matches.
console.error("Unable to queue tournament match refresh");
}
};
Loading
Loading