Skip to content

Commit 9bb8b01

Browse files
authored
fix: add automatic queue self-healing and retry unblocking (#97)
* fix: add automatic queue self-healing * fix: avoid queue starvation during retries * fix: requeue stale active jobs without builds
1 parent 6faa748 commit 9bb8b01

7 files changed

Lines changed: 148 additions & 11 deletions

File tree

functions/src/cron/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ if (MINUTES < 10) {
1717
throw new Error('Is the result really worth the machine time? Remove me.');
1818
}
1919

20-
// Timeout of 60 seconds will keep our routine process tight.
20+
// Allow enough time for DockerHub-backed healing work during queue recovery.
2121
export const trigger = onSchedule(
2222
{
2323
schedule: `every ${MINUTES} minutes`,
2424
memory: '512MiB',
25-
timeoutSeconds: 60,
25+
timeoutSeconds: 300,
2626
secrets: [discordToken, githubPrivateKeyConfigSecret, githubClientSecretConfigSecret],
2727
},
2828
async () => {
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Cleaner } from './cleaner';
2+
import { RepoVersionInfo } from '../../model/repoVersionInfo';
23

34
export const cleanUpBuilds = async () => {
4-
await Cleaner.cleanUp();
5+
const latestRepoVersion = await RepoVersionInfo.getLatest();
6+
await Cleaner.cleanUp(latestRepoVersion.version);
57
};

functions/src/logic/buildQueue/cleaner.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import { CiBuilds } from '../../model/ciBuilds';
2+
import { CiJobs } from '../../model/ciJobs';
23
import { Discord } from '../../service/discord';
34
import { Dockerhub } from '../../service/dockerhub';
45

56
export class Cleaner {
6-
// Cronjob intentionally has a limited runtime
7-
static readonly maxBuildsProcessedPerRun: number = 5;
7+
// Cronjob intentionally has a limited runtime, but it still needs enough
8+
// throughput to drain queue backlogs without requiring manual intervention.
9+
static readonly maxBuildsProcessedPerRun: number = 25;
10+
static readonly activeJobWithoutBuildsAfterMinutes: number = 30;
11+
static readonly startedBuildPublishProbeAfterMinutes: number = 45;
12+
static readonly startedBuildFailureAfterHours: number = 6;
813

914
static buildsProcessed: number;
1015

11-
public static async cleanUp() {
16+
public static async cleanUp(latestRepoVersion: string) {
1217
this.buildsProcessed = 0;
18+
await this.requeueActiveJobsWithoutBuilds();
19+
await this.recoverMaxedOutFailedBuilds(latestRepoVersion);
20+
await this.reconcileStartedBuildsThatMayHavePublished();
1321
await this.cleanUpBuildsThatDidntReportBack();
1422
await this.healFailedBuildsAlreadyOnDockerHub();
1523
}
@@ -22,6 +30,36 @@ export class Cleaner {
2230
*/
2331
static readonly maxRecoveryAttempts: number = 2;
2432

33+
/**
34+
* Jobs can get stuck in "scheduled" or "inProgress" without ever creating a
35+
* build record when the dispatch path flakes before reportNewBuild. Reset
36+
* those jobs back to created so the scheduler can dispatch them again.
37+
*/
38+
private static async requeueActiveJobsWithoutBuilds() {
39+
const activeJobs = await CiJobs.getActiveJobs();
40+
const staleThresholdMs = this.activeJobWithoutBuildsAfterMinutes * 60 * 1000;
41+
42+
for (const activeJob of activeJobs) {
43+
if (this.buildsProcessed >= this.maxBuildsProcessedPerRun) return;
44+
45+
const { id: jobId, data: job } = activeJob;
46+
const lastTouchedSeconds = job.modifiedDate?.seconds || job.addedDate?.seconds;
47+
if (!lastTouchedSeconds) continue;
48+
49+
const ageMs = Date.now() - lastTouchedSeconds * 1000;
50+
if (ageMs < staleThresholdMs) continue;
51+
52+
const hasBuilds = await CiBuilds.hasAnyBuildsForJob(jobId);
53+
if (hasBuilds) continue;
54+
55+
this.buildsProcessed += 1;
56+
await CiJobs.resetJobToCreated(jobId);
57+
await Discord.sendAlert(
58+
`[Cleaner] Reset stale ${job.status} job "${jobId}" back to created because it had no build records after ${this.activeJobWithoutBuildsAfterMinutes} minutes.`,
59+
);
60+
}
61+
}
62+
2563
/**
2664
* Automatically recover maxed-out failed builds for the latest repo version.
2765
* If the image is already on DockerHub, mark it as published.
@@ -183,6 +221,49 @@ export class Cleaner {
183221
}
184222
}
185223

224+
/**
225+
* If a started build has been running for a while and the image already
226+
* exists on DockerHub, we can mark it as published without waiting for the
227+
* full GitHub Actions timeout window. This frees queue capacity earlier.
228+
*/
229+
private static async reconcileStartedBuildsThatMayHavePublished() {
230+
const startedBuilds = await CiBuilds.getStartedBuilds();
231+
const probeThresholdMs = this.startedBuildPublishProbeAfterMinutes * 60 * 1000;
232+
233+
for (const startedBuild of startedBuilds) {
234+
if (this.buildsProcessed >= this.maxBuildsProcessedPerRun) return;
235+
236+
const { buildId, meta, relatedJobId: jobId, imageType, buildInfo } = startedBuild;
237+
const { lastBuildStart, publishedDate } = meta;
238+
const { baseOs, repoVersion } = buildInfo;
239+
240+
if (!lastBuildStart) continue;
241+
242+
const buildStartMs = new Date(lastBuildStart.seconds * 1000).getTime();
243+
const nowMs = Date.now();
244+
if (nowMs - buildStartMs < probeThresholdMs) continue;
245+
246+
const tag = buildId.replace(new RegExp(`^${imageType}-`), '');
247+
this.buildsProcessed += 1;
248+
249+
const response = await Dockerhub.fetchImageData(imageType, tag);
250+
if (!response) continue;
251+
252+
const digest = response.digest || '';
253+
const message = publishedDate
254+
? `[Cleaner] Build "${tag}" has published metadata and exists on DockerHub. Reconciling status back to published.`
255+
: `[Cleaner] Build "${tag}" is still "started" but already exists on DockerHub. Marking as published early.`;
256+
await Discord.sendDebug(message);
257+
await CiBuilds.markBuildAsPublished(buildId, jobId, {
258+
digest,
259+
specificTag: `${baseOs}-${repoVersion}`,
260+
friendlyTag: repoVersion.replace(/\.\d+$/, ''),
261+
imageName: Dockerhub.getImageName(imageType),
262+
imageRepo: Dockerhub.getRepositoryBaseName(),
263+
});
264+
}
265+
}
266+
186267
private static async cleanUpBuildsThatDidntReportBack() {
187268
const startedBuilds = await CiBuilds.getStartedBuilds();
188269

@@ -218,7 +299,8 @@ export class Cleaner {
218299
// If a job reaches this limit, the job is terminated and fails to complete.
219300
// @see https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration
220301
const ONE_HOUR = 1000 * 60 * 60;
221-
const sixHoursAgo = new Date().getTime() - 6 * ONE_HOUR;
302+
const failureThresholdMs = this.startedBuildFailureAfterHours * ONE_HOUR;
303+
const sixHoursAgo = new Date().getTime() - failureThresholdMs;
222304
const buildStart = new Date(lastBuildStart.seconds * 1000).getTime();
223305

224306
if (buildStart < sixHoursAgo) {

functions/src/logic/buildQueue/ingeminator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import { logger } from 'firebase-functions/v2';
1414
export class Ingeminator {
1515
numberToSchedule: number;
1616
gitHubClient: Octokit;
17+
private scheduledBuilds = 0;
1718

1819
constructor(numberToSchedule: number, gitHubClient: Octokit) {
1920
this.numberToSchedule = numberToSchedule;
2021
this.gitHubClient = gitHubClient;
2122
}
2223

23-
async rescheduleFailedJobs(jobs: CiJobQueue) {
24+
async rescheduleFailedJobs(jobs: CiJobQueue): Promise<number> {
2425
if (jobs.length <= 0) {
2526
throw new Error(
2627
'[Ingeminator] Expected ingeminator to be called with jobs to retry, none were given.',
@@ -36,6 +37,8 @@ export class Ingeminator {
3637

3738
await this.rescheduleFailedBuildsForJob(job);
3839
}
40+
41+
return this.scheduledBuilds;
3942
}
4043

4144
private async rescheduleFailedBuildsForJob(job: CiJobQueueItem) {
@@ -101,6 +104,7 @@ export class Ingeminator {
101104
if (!(await this.rescheduleBuild(jobId, jobData, buildId, BuildData))) {
102105
return;
103106
}
107+
this.scheduledBuilds += 1;
104108
}
105109

106110
await CiJobs.markJobAsScheduled(jobId);

functions/src/logic/buildQueue/scheduler.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export class Scheduler {
1919
private _gitHub: Octokit | undefined;
2020
private maxConcurrentJobs: number;
2121
private repoVersionInfo: RepoVersionInfo;
22+
private reservedRetrySlots = 0;
2223

2324
private get gitHub(): Octokit {
2425
// @ts-ignore
@@ -166,6 +167,7 @@ export class Scheduler {
166167
async ensureThereAreNoFailedJobs(): Promise<boolean> {
167168
const { maxToleratedFailures, maxExtraJobsForRescheduling } = settings;
168169
const failingJobs = await CiJobs.getFailingJobsQueue();
170+
this.reservedRetrySlots = 0;
169171

170172
if (failingJobs.length >= 1) {
171173
const openSpots = await this.determineOpenSpots();
@@ -177,14 +179,23 @@ export class Scheduler {
177179
}
178180

179181
const ingeminator = new Ingeminator(numberToReschedule, this.gitHub);
180-
await ingeminator.rescheduleFailedJobs(failingJobs);
182+
const scheduledRetries = await ingeminator.rescheduleFailedJobs(failingJobs);
183+
this.reservedRetrySlots = Math.min(scheduledRetries, openSpots);
184+
185+
const remainingFreshSlots = openSpots - this.reservedRetrySlots;
186+
if (remainingFreshSlots > 0) {
187+
await Discord.sendDebug(
188+
`[Scheduler] Reserved ${this.reservedRetrySlots} slot(s) for retries and kept ${remainingFreshSlots} slot(s) available for fresh jobs.`,
189+
);
190+
}
181191
}
182192

183-
return failingJobs.length <= maxToleratedFailures;
193+
const openSpotsAfterRetries = await this.determineOpenSpotsForFreshJobs();
194+
return failingJobs.length <= maxToleratedFailures || openSpotsAfterRetries > 0;
184195
}
185196

186197
async buildLatestEditorImages(): Promise<boolean> {
187-
const openSpots = await this.determineOpenSpots();
198+
const openSpots = await this.determineOpenSpotsForFreshJobs();
188199
if (openSpots <= 0) {
189200
await Discord.sendDebug('[Scheduler] Not scheduling any new jobs, as the queue is full');
190201
return false;
@@ -246,4 +257,10 @@ export class Scheduler {
246257
const openSpots = this.maxConcurrentJobs - currentlyRunningJobs;
247258
return openSpots <= 0 ? 0 : openSpots;
248259
}
260+
261+
private async determineOpenSpotsForFreshJobs(): Promise<number> {
262+
const openSpots = await this.determineOpenSpots();
263+
const availableSpots = openSpots - this.reservedRetrySlots;
264+
return availableSpots <= 0 ? 0 : availableSpots;
265+
}
249266
}

functions/src/model/ciBuilds.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ export class CiBuilds {
116116
}));
117117
};
118118

119+
public static hasAnyBuildsForJob = async (jobId: string): Promise<boolean> => {
120+
const snapshot = await db
121+
.collection(CiBuilds.collection)
122+
.where('relatedJobId', '==', jobId)
123+
.limit(1)
124+
.get();
125+
126+
return snapshot.docs.length > 0;
127+
};
128+
119129
/**
120130
* Registers a new build or handles duplicate dispatches gracefully.
121131
* Returns the existing status if the build is already in progress or published,

functions/src/model/ciJobs.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ export class CiJobs {
126126
return snapshot.docs.length;
127127
};
128128

129+
static getActiveJobs = async (): Promise<CiJobQueue> => {
130+
const snapshot = await db
131+
.collection(CiJobs.collection)
132+
.where('status', 'in', ['scheduled', 'inProgress'])
133+
.limit(settings.maxConcurrentJobs)
134+
.get();
135+
136+
return snapshot.docs.map((doc) => ({
137+
id: doc.id,
138+
data: doc.data() as CiJob,
139+
}));
140+
};
141+
129142
static create = async (
130143
jobId: string,
131144
imageType: ImageType,
@@ -236,6 +249,15 @@ export class CiJobs {
236249
});
237250
};
238251

252+
static resetJobToCreated = async (jobId: string) => {
253+
const job = await db.collection(CiJobs.collection).doc(jobId);
254+
255+
await job.update({
256+
status: 'created',
257+
modifiedDate: Timestamp.now(),
258+
});
259+
};
260+
239261
static async removeDryRunJob(jobId: string) {
240262
if (!jobId.startsWith('dryRun')) {
241263
throw new Error('Expect only dryRun jobs to be deleted.');

0 commit comments

Comments
 (0)