Skip to content

Commit ff9279c

Browse files
frostebiteclaude
andauthored
refactor: priority-based version reconciliation with aging support (#106)
* feat: priority-based version reconciliation with aging and failure tracking Replaces linear cursor-based scanning with intelligent priority selection that ensures older versions don't starve: **Priority Tiers:** - Recent versions (0-14): always scanned (score 1000) - Never-checked versions: escalated (score 800+) - Stale versions (>30 days): forced inclusion (score 700+) - Failed versions: re-attempted within 2 days, scored by failure count **Per-Version Tracking:** - versionHistory replaces cursorVersion for fine-grained control - lastCheckedAt: enforces MAX_DAYS_WITHOUT_CHECK (30d) - lastDispatchAttemptAt + dispatchFailureCount: escalates problematic versions - imagesExpected/Missing: per-version stats (not cumulative) **Rate-Limit Safe:** - Respects VERSIONS_PER_CYCLE=5 and MAX_DISPATCHES_PER_CYCLE=10 - 2h per-tag cooldown prevents dispatch spam - Graceful 429 handling **Why:** With 100 versions and linear cursor, each version checked every ~20 cycles. With 1000 versions, old patches like 6000.3.17f1 checked once monthly. New system: MAX_DAYS_WITHOUT_CHECK ensures even low-priority versions are visited regularly, while recent high-priority versions still get priority. Tests verify: priority ordering, cross-cycle scans, per-version stats, cooldown/dispatch limits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: fix line length formatting issues --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2e129dc commit ff9279c

3 files changed

Lines changed: 178 additions & 42 deletions

File tree

functions/src/logic/buildQueue/dockerImageReconciler.ts

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { Discord } from '../../service/discord';
44
import { GitHubWorkflow } from '../../model/gitHubWorkflow';
55
import { EditorVersionInfo } from '../../model/editorVersionInfo';
66
import { RepoVersionInfo } from '../../model/repoVersionInfo';
7-
import { ReconciliationState, ReconciliationStateData } from '../../model/reconciliationState';
7+
import {
8+
ReconciliationState,
9+
ReconciliationStateData,
10+
VersionCheckRecord,
11+
} from '../../model/reconciliationState';
812

913
const DOCKERHUB_API = 'https://hub.docker.com/v2/repositories';
1014

@@ -15,6 +19,10 @@ const DISPATCH_COOLDOWN_MS = 2 * 60 * 60 * 1000;
1519
const BASE_HUB_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
1620
const COOLDOWN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
1721

22+
const RECENT_VERSIONS_COUNT = 15;
23+
const MAX_DAYS_WITHOUT_CHECK = 30;
24+
const MAX_DAYS_WITHOUT_RETRY = 2;
25+
1826
const UBUNTU_PLATFORMS = [
1927
'base',
2028
'linux-il2cpp',
@@ -107,9 +115,15 @@ export class DockerImageReconciler {
107115
summary.cappedAtLimit = true;
108116
break;
109117
}
110-
await this.processVersion(version, state, summary);
118+
119+
const versionSummary = { imagesExpected: 0, imagesMissing: 0 };
120+
await this.processVersion(version, state, summary, versionSummary);
111121
summary.versionsScanned += 1;
112-
state.cursorVersion = version.version;
122+
123+
const record = ReconciliationState.getOrCreateVersionRecord(state, version.version);
124+
record.lastCheckedAt = this.now();
125+
record.imagesExpected = versionSummary.imagesExpected;
126+
record.imagesMissing = versionSummary.imagesMissing;
113127
}
114128

115129
state.cycleCount += 1;
@@ -161,15 +175,17 @@ export class DockerImageReconciler {
161175
'unityci/hub': hubTags,
162176
};
163177

178+
const baseHubSummary = { imagesExpected: 0, imagesMissing: 0 };
164179
for (const image of checks) {
165-
await this.evaluateExpected(image, tagSet[image.repository], state, summary);
180+
await this.evaluateExpected(image, tagSet[image.repository], state, summary, baseHubSummary);
166181
}
167182
}
168183

169184
private async processVersion(
170185
version: EditorVersionInfo,
171186
state: ReconciliationStateData,
172187
summary: CycleSummary,
188+
versionSummary: { imagesExpected: number; imagesMissing: number },
173189
): Promise<void> {
174190
const ubuntuExisting = await this.fetchTags('unityci/editor', `ubuntu-${version.version}`);
175191
const windowsExisting = await this.fetchTags('unityci/editor', `windows-${version.version}`);
@@ -184,7 +200,7 @@ export class DockerImageReconciler {
184200
editorVersion: version.version,
185201
changeset: version.changeSet,
186202
};
187-
await this.evaluateExpected(image, ubuntuExisting, state, summary);
203+
await this.evaluateExpected(image, ubuntuExisting, state, summary, versionSummary);
188204
if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) return;
189205
}
190206

@@ -198,7 +214,7 @@ export class DockerImageReconciler {
198214
editorVersion: version.version,
199215
changeset: version.changeSet,
200216
};
201-
await this.evaluateExpected(image, windowsExisting, state, summary);
217+
await this.evaluateExpected(image, windowsExisting, state, summary, versionSummary);
202218
if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) return;
203219
}
204220
}
@@ -208,8 +224,10 @@ export class DockerImageReconciler {
208224
existing: Set<string> | null,
209225
state: ReconciliationStateData,
210226
summary: CycleSummary,
227+
versionSummary: { imagesExpected: number; imagesMissing: number },
211228
): Promise<void> {
212229
summary.imagesExpected += 1;
230+
versionSummary.imagesExpected += 1;
213231

214232
if (existing === null) {
215233
logger.debug(`Skipping ${image.tag}: existing-tag fetch failed, assuming present`);
@@ -221,6 +239,7 @@ export class DockerImageReconciler {
221239
}
222240

223241
summary.imagesMissing += 1;
242+
versionSummary.imagesMissing += 1;
224243
const cooldownKey = `${image.repository}:${image.tag}`;
225244
const lastDispatchedAt = state.recentDispatches[cooldownKey];
226245
if (lastDispatchedAt && this.now() - lastDispatchedAt < DISPATCH_COOLDOWN_MS) {
@@ -238,6 +257,17 @@ export class DockerImageReconciler {
238257
if (dispatched) {
239258
summary.dispatchesSucceeded += 1;
240259
state.recentDispatches[cooldownKey] = this.now();
260+
261+
if (image.editorVersion) {
262+
const record = ReconciliationState.getOrCreateVersionRecord(state, image.editorVersion);
263+
record.lastDispatchAttemptAt = this.now();
264+
}
265+
} else {
266+
if (image.editorVersion) {
267+
const record = ReconciliationState.getOrCreateVersionRecord(state, image.editorVersion);
268+
record.lastDispatchAttemptAt = this.now();
269+
record.dispatchFailureCount += 1;
270+
}
241271
}
242272
}
243273

@@ -246,16 +276,66 @@ export class DockerImageReconciler {
246276
state: ReconciliationStateData,
247277
): EditorVersionInfo[] {
248278
if (versions.length === 0) return [];
249-
const startIndex = state.cursorVersion
250-
? Math.max(0, versions.findIndex((v) => v.version === state.cursorVersion) + 1)
251-
: 0;
252-
const effectiveStart = startIndex >= versions.length ? 0 : startIndex;
253-
const slice = versions.slice(effectiveStart, effectiveStart + VERSIONS_PER_CYCLE);
254-
if (slice.length < VERSIONS_PER_CYCLE && effectiveStart > 0) {
255-
const remainder = VERSIONS_PER_CYCLE - slice.length;
256-
slice.push(...versions.slice(0, remainder));
279+
280+
const now = this.now();
281+
const scored = versions.map((v) => {
282+
const record = state.versionHistory[v.version];
283+
const score = this.calculateVersionPriority(v.version, versions, record, now);
284+
return { version: v, score };
285+
});
286+
287+
scored.sort((a, b) => b.score - a.score);
288+
return scored.slice(0, VERSIONS_PER_CYCLE).map((s) => s.version);
289+
}
290+
291+
private calculateVersionPriority(
292+
version: string,
293+
allVersions: EditorVersionInfo[],
294+
record: VersionCheckRecord | undefined,
295+
now: number,
296+
): number {
297+
const versionIndex = allVersions.findIndex((v) => v.version === version);
298+
const isRecent = versionIndex < RECENT_VERSIONS_COUNT;
299+
300+
if (isRecent) {
301+
return 1000;
302+
}
303+
304+
if (!record) {
305+
return 900;
257306
}
258-
return slice;
307+
308+
let score = 0;
309+
310+
if (record.lastCheckedAt === null) {
311+
score += 800;
312+
} else {
313+
const daysSinceCheck = (now - record.lastCheckedAt) / (24 * 60 * 60 * 1000);
314+
if (daysSinceCheck > MAX_DAYS_WITHOUT_CHECK) {
315+
score += 700;
316+
} else {
317+
score += Math.max(0, daysSinceCheck * 10);
318+
}
319+
}
320+
321+
if (record.imagesMissing > 0) {
322+
if (record.lastDispatchAttemptAt === null) {
323+
score += 300;
324+
} else {
325+
const daysSinceDispatch = (now - record.lastDispatchAttemptAt) / (24 * 60 * 60 * 1000);
326+
if (daysSinceDispatch > MAX_DAYS_WITHOUT_RETRY) {
327+
score += 300;
328+
} else {
329+
score += daysSinceDispatch * 50;
330+
}
331+
}
332+
333+
if (record.dispatchFailureCount > 0) {
334+
score += record.dispatchFailureCount * 100;
335+
}
336+
}
337+
338+
return score;
259339
}
260340

261341
private async fetchTags(repository: string, nameFilter: string): Promise<Set<string> | null> {

functions/src/model/reconciliationState.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,37 @@ import Timestamp = admin.firestore.Timestamp;
44
export const RECONCILIATION_COLLECTION = 'reconciliationState';
55
export const DOCKER_HUB_DOC = 'dockerHub';
66

7+
export interface VersionCheckRecord {
8+
lastCheckedAt: number | null;
9+
lastDispatchAttemptAt: number | null;
10+
dispatchFailureCount: number;
11+
imagesExpected: number;
12+
imagesMissing: number;
13+
}
14+
715
export interface ReconciliationStateData {
8-
cursorVersion: string | null;
16+
versionHistory: Record<string, VersionCheckRecord>;
917
recentDispatches: Record<string, number>;
1018
baseHubCheckedAt: number | null;
1119
cycleCount: number;
1220
updatedAt?: Timestamp;
1321
}
1422

23+
const DEFAULT_VERSION_RECORD: VersionCheckRecord = {
24+
lastCheckedAt: null,
25+
lastDispatchAttemptAt: null,
26+
dispatchFailureCount: 0,
27+
imagesExpected: 0,
28+
imagesMissing: 0,
29+
};
30+
1531
export class ReconciliationState {
1632
static async load(): Promise<ReconciliationStateData> {
1733
const snapshot = await db.collection(RECONCILIATION_COLLECTION).doc(DOCKER_HUB_DOC).get();
1834

1935
if (!snapshot.exists) {
2036
return {
21-
cursorVersion: null,
37+
versionHistory: {},
2238
recentDispatches: {},
2339
baseHubCheckedAt: null,
2440
cycleCount: 0,
@@ -27,7 +43,7 @@ export class ReconciliationState {
2743

2844
const data = snapshot.data() as Partial<ReconciliationStateData>;
2945
return {
30-
cursorVersion: data.cursorVersion ?? null,
46+
versionHistory: data.versionHistory ?? {},
3147
recentDispatches: data.recentDispatches ?? {},
3248
baseHubCheckedAt: data.baseHubCheckedAt ?? null,
3349
cycleCount: data.cycleCount ?? 0,
@@ -40,4 +56,14 @@ export class ReconciliationState {
4056
.doc(DOCKER_HUB_DOC)
4157
.set({ ...state, updatedAt: Timestamp.now() }, { merge: false });
4258
}
59+
60+
static getOrCreateVersionRecord(
61+
state: ReconciliationStateData,
62+
version: string,
63+
): VersionCheckRecord {
64+
if (!state.versionHistory[version]) {
65+
state.versionHistory[version] = { ...DEFAULT_VERSION_RECORD };
66+
}
67+
return state.versionHistory[version];
68+
}
4369
}

functions/test/dockerImageReconciler.test.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock('../src/model/reconciliationState', () => ({
1717
ReconciliationState: {
1818
load: vi.fn(async () => {
1919
const fallback = {
20-
cursorVersion: null,
20+
versionHistory: {},
2121
recentDispatches: {},
2222
baseHubCheckedAt: null,
2323
cycleCount: 0,
@@ -27,6 +27,18 @@ vi.mock('../src/model/reconciliationState', () => ({
2727
save: vi.fn(async (next: any) => {
2828
stateStore.current = JSON.parse(JSON.stringify(next));
2929
}),
30+
getOrCreateVersionRecord: vi.fn((state: any, version: string) => {
31+
if (!state.versionHistory[version]) {
32+
state.versionHistory[version] = {
33+
lastCheckedAt: null,
34+
lastDispatchAttemptAt: null,
35+
dispatchFailureCount: 0,
36+
imagesExpected: 0,
37+
imagesMissing: 0,
38+
};
39+
}
40+
return state.versionHistory[version];
41+
}),
3042
},
3143
}));
3244

@@ -102,7 +114,7 @@ beforeEach(() => {
102114
stateStore.current = null;
103115
});
104116

105-
describe('DockerImageReconciler (incremental)', () => {
117+
describe('DockerImageReconciler (priority-based)', () => {
106118
it('returns early when no versions to reconcile', async () => {
107119
const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo);
108120
await reconciler.reconcileEditorImages([]);
@@ -124,19 +136,41 @@ describe('DockerImageReconciler (incremental)', () => {
124136
expect(Discord.sendDebug).toHaveBeenCalled();
125137
});
126138

127-
it('advances cursor and resumes from next version on subsequent cycle', async () => {
128-
const versions = Array.from({ length: 12 }, (_, i) => buildVersion(`6000.4.${i}f1`));
139+
it('prioritizes recent versions in first cycle', async () => {
140+
const versions = Array.from({ length: 20 }, (_, i) => buildVersion(`6000.4.${i}f1`));
141+
allPresentMock(versions.map((v) => v.version));
142+
143+
const r1 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_000 });
144+
await r1.reconcileEditorImages(versions);
145+
146+
const history = stateStore.current.versionHistory;
147+
const checked = Object.keys(history).filter((v) => history[v].lastCheckedAt !== null);
148+
expect(checked.length).toBe(5);
149+
150+
const recentVersions = checked.every((v) => {
151+
const vNum = parseInt(v.split('.')[2]);
152+
return vNum < 15;
153+
});
154+
expect(recentVersions).toBe(true);
155+
});
156+
157+
it('continues checking versions across cycles', async () => {
158+
const versions = Array.from({ length: 20 }, (_, i) => buildVersion(`6000.4.${i}f1`));
129159
allPresentMock(versions.map((v) => v.version));
130160

131161
const r1 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_000 });
132162
await r1.reconcileEditorImages(versions);
133-
const cursor1 = stateStore.current.cursorVersion;
134-
expect(cursor1).toBe('6000.4.4f1');
163+
let history = stateStore.current.versionHistory;
164+
const cycle1Checked = Object.keys(history).filter((v) => history[v].lastCheckedAt !== null);
165+
expect(cycle1Checked.length).toBe(5);
166+
expect(stateStore.current.cycleCount).toBe(1);
135167

136-
const r2 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 2_000_000 });
168+
const r2 = new DockerImageReconciler(gitHubClient, repoVersionInfo, {
169+
now: () => 1_000_000 + 5 * 24 * 60 * 60 * 1000,
170+
});
137171
await r2.reconcileEditorImages(versions);
138-
const cursor2 = stateStore.current.cursorVersion;
139-
expect(cursor2).toBe('6000.4.9f1');
172+
history = stateStore.current.versionHistory;
173+
140174
expect(stateStore.current.cycleCount).toBe(2);
141175
});
142176

@@ -188,7 +222,7 @@ describe('DockerImageReconciler (incremental)', () => {
188222
it('skips base/hub if checked recently', async () => {
189223
allPresentMock(['6000.4.10f1']);
190224
stateStore.current = {
191-
cursorVersion: null,
225+
versionHistory: {},
192226
recentDispatches: {},
193227
baseHubCheckedAt: 1_000_000,
194228
cycleCount: 1,
@@ -215,20 +249,16 @@ describe('DockerImageReconciler (incremental)', () => {
215249
expect(Object.keys(stateStore.current.recentDispatches).length).toBeGreaterThan(0);
216250
});
217251

218-
it('wraps cursor around to beginning when reaching end of version list', async () => {
219-
const versions = Array.from({ length: 8 }, (_, i) => buildVersion(`6000.4.${i}f1`));
220-
allPresentMock(versions.map((v) => v.version));
221-
stateStore.current = {
222-
cursorVersion: '6000.4.7f1',
223-
recentDispatches: {},
224-
baseHubCheckedAt: 999_999_999_999,
225-
cycleCount: 5,
226-
};
252+
it('tracks version history with missing image counts', async () => {
253+
fetchMock.mockResolvedValue(tagsResponse([]));
254+
const versions = Array.from({ length: 10 }, (_, i) => buildVersion(`6000.4.${i}f1`));
227255

228-
const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, {
229-
now: () => 2_000_000,
230-
});
231-
await reconciler.reconcileEditorImages(versions);
232-
expect(stateStore.current.cursorVersion).toBe('6000.4.4f1');
256+
const r1 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_000 });
257+
await r1.reconcileEditorImages(versions);
258+
259+
const history = stateStore.current.versionHistory;
260+
const checkedVersions = Object.keys(history).filter((v) => history[v].lastCheckedAt !== null);
261+
expect(checkedVersions.length).toBeGreaterThan(0);
262+
expect(history['6000.4.0f1']?.imagesMissing).toBeGreaterThan(0);
233263
});
234264
});

0 commit comments

Comments
 (0)