Skip to content

Commit 0db4af7

Browse files
committed
fix: requeue stale active jobs without builds
1 parent 9ddee01 commit 0db4af7

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

functions/src/logic/buildQueue/cleaner.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
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 {
67
// Cronjob intentionally has a limited runtime, but it still needs enough
78
// throughput to drain queue backlogs without requiring manual intervention.
89
static readonly maxBuildsProcessedPerRun: number = 25;
10+
static readonly activeJobWithoutBuildsAfterMinutes: number = 30;
911
static readonly startedBuildPublishProbeAfterMinutes: number = 45;
1012
static readonly startedBuildFailureAfterHours: number = 6;
1113

1214
static buildsProcessed: number;
1315

1416
public static async cleanUp(latestRepoVersion: string) {
1517
this.buildsProcessed = 0;
18+
await this.requeueActiveJobsWithoutBuilds();
1619
await this.recoverMaxedOutFailedBuilds(latestRepoVersion);
1720
await this.reconcileStartedBuildsThatMayHavePublished();
1821
await this.cleanUpBuildsThatDidntReportBack();
@@ -27,6 +30,36 @@ export class Cleaner {
2730
*/
2831
static readonly maxRecoveryAttempts: number = 2;
2932

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+
3063
/**
3164
* Automatically recover maxed-out failed builds for the latest repo version.
3265
* If the image is already on DockerHub, mark it as published.

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)