11import { CiBuilds } from '../../model/ciBuilds' ;
2+ import { CiJobs } from '../../model/ciJobs' ;
23import { Discord } from '../../service/discord' ;
34import { Dockerhub } from '../../service/dockerhub' ;
45
56export 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 ) {
0 commit comments