bulltrackers-module 1.0.775 → 1.0.777
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Scheduler V3.
|
|
3
|
-
* * * 1. Reconcile:
|
|
4
|
-
* * 2. Purge:
|
|
2
|
+
* @fileoverview Scheduler V3.3: Smart Reconciliation & Future-Aligned Backfills
|
|
3
|
+
* * * 1. Reconcile: Checks ENTIRE graph for stale hashes, triggers ROOTS if needed.
|
|
4
|
+
* * 2. Purge: Actively removes tasks that don't match the current deployment hash.
|
|
5
5
|
* * 3. Watchdog: Recovers "Zombie" tasks (running but stuck).
|
|
6
|
+
* * * UPDATE: Backfills now respect the 'next available window' instead of running instantly.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const { CloudTasksClient } = require('@google-cloud/tasks');
|
|
@@ -41,8 +42,10 @@ async function initialize() {
|
|
|
41
42
|
|
|
42
43
|
console.log(`[Scheduler] Loaded ${manifest.length} computations.`);
|
|
43
44
|
}
|
|
45
|
+
|
|
44
46
|
/**
|
|
45
|
-
*
|
|
47
|
+
* ENTRY POINT 1: The Planner
|
|
48
|
+
* Trigger: Cloud Scheduler -> "0 * * * *" (Hourly)
|
|
46
49
|
*/
|
|
47
50
|
async function planComputations(req, res) {
|
|
48
51
|
try {
|
|
@@ -106,18 +109,22 @@ async function planComputations(req, res) {
|
|
|
106
109
|
if (reason) {
|
|
107
110
|
// If entry is stale, we must schedule its ROOT(s) to trigger the chain
|
|
108
111
|
const roots = getRoots(entry);
|
|
109
|
-
|
|
112
|
+
|
|
110
113
|
roots.forEach(root => {
|
|
114
|
+
// Unique Task Key: RootName + Date + Hash
|
|
111
115
|
const taskKey = `root-${toKebab(root.originalName)}-${dateStr}-${root.hash}`;
|
|
112
|
-
|
|
116
|
+
|
|
113
117
|
if (!tasksToSchedule.has(taskKey)) {
|
|
118
|
+
// NEW: Calculate the NEXT valid run window instead of using the past date
|
|
119
|
+
const runAt = getNextRunWindow(root.schedule, dateObj);
|
|
120
|
+
|
|
114
121
|
tasksToSchedule.set(taskKey, {
|
|
115
122
|
computation: root.originalName,
|
|
116
123
|
targetDate: dateStr,
|
|
117
|
-
runAtSeconds:
|
|
124
|
+
runAtSeconds: runAt,
|
|
118
125
|
configHash: root.hash,
|
|
119
126
|
queuePath: getQueuePath(),
|
|
120
|
-
reason: `TRIGGERED_BY_${entry.name}_${reason}`
|
|
127
|
+
reason: `TRIGGERED_BY_${entry.name}_${reason}`
|
|
121
128
|
});
|
|
122
129
|
}
|
|
123
130
|
});
|
|
@@ -126,24 +133,28 @@ async function planComputations(req, res) {
|
|
|
126
133
|
})));
|
|
127
134
|
|
|
128
135
|
// --- PHASE 2: GARBAGE COLLECTION ---
|
|
129
|
-
// (Keep your existing GC logic here)
|
|
130
136
|
console.log('[Planner] Starting Garbage Collection...');
|
|
131
137
|
const deletedCount = await cleanupOrphanedTasks();
|
|
132
138
|
|
|
133
139
|
// --- PHASE 3: DISPATCH ---
|
|
134
140
|
const taskList = Array.from(tasksToSchedule.values());
|
|
135
141
|
let scheduledCount = 0;
|
|
142
|
+
let existsCount = 0;
|
|
143
|
+
|
|
136
144
|
if (taskList.length > 0) {
|
|
137
145
|
const results = await dispatchTasks(taskList);
|
|
138
146
|
scheduledCount = results.filter(r => r.status === 'scheduled').length;
|
|
147
|
+
existsCount = results.filter(r => r.status === 'exists').length;
|
|
139
148
|
}
|
|
140
149
|
|
|
141
|
-
console.log(`[Planner] Complete. Scheduled: ${scheduledCount}, Deleted Orphans: ${deletedCount}`);
|
|
142
|
-
|
|
150
|
+
console.log(`[Planner] Complete. Scheduled: ${scheduledCount} (Exists/Tombstoned: ${existsCount}), Deleted Orphans: ${deletedCount}`);
|
|
151
|
+
|
|
143
152
|
return res.status(200).json({
|
|
144
153
|
status: 'success',
|
|
145
154
|
window: `${PLANNING_LOOKBACK_DAYS}d back`,
|
|
146
155
|
scheduled: scheduledCount,
|
|
156
|
+
exists: existsCount,
|
|
157
|
+
deleted: deletedCount,
|
|
147
158
|
stats
|
|
148
159
|
});
|
|
149
160
|
|
|
@@ -155,7 +166,6 @@ async function planComputations(req, res) {
|
|
|
155
166
|
|
|
156
167
|
/**
|
|
157
168
|
* ENTRY POINT 2: The Watchdog
|
|
158
|
-
* Trigger: Cloud Scheduler -> "*\/15 * * * *" (Every 15 mins)
|
|
159
169
|
*/
|
|
160
170
|
async function runWatchdog(req, res) {
|
|
161
171
|
try {
|
|
@@ -188,85 +198,55 @@ async function runWatchdog(req, res) {
|
|
|
188
198
|
return res.status(500).json({ error: error.message });
|
|
189
199
|
}
|
|
190
200
|
}
|
|
201
|
+
|
|
191
202
|
// =============================================================================
|
|
192
203
|
// ACTIVE GARBAGE COLLECTION LOGIC
|
|
193
204
|
// =============================================================================
|
|
194
205
|
|
|
195
206
|
async function cleanupOrphanedTasks() {
|
|
196
207
|
const parent = getQueuePath();
|
|
197
|
-
|
|
198
|
-
// Create a map of { kebabName: activeHash } for O(1) lookups
|
|
199
|
-
const activeComputations = new Map(
|
|
200
|
-
manifest.map(m => [toKebab(m.originalName), m.hash])
|
|
201
|
-
);
|
|
202
|
-
|
|
208
|
+
const activeComputations = new Map(manifest.map(m => [toKebab(m.originalName), m.hash]));
|
|
203
209
|
const limit = pLimit(CLOUD_TASKS_CONCURRENCY);
|
|
204
210
|
let deletedCount = 0;
|
|
205
211
|
|
|
206
212
|
try {
|
|
207
213
|
const tasksToDelete = [];
|
|
208
|
-
|
|
209
|
-
// Note: listTasksAsync handles pagination, but if you have thousands of tasks,
|
|
210
|
-
// you might eventually need to handle page tokens explicitly if the library version is old.
|
|
211
|
-
for await (const task of tasksClient.listTasksAsync({
|
|
212
|
-
parent,
|
|
213
|
-
responseView: 'BASIC',
|
|
214
|
-
pageSize: 1000 // Increase page size to capture more per request
|
|
215
|
-
})) {
|
|
214
|
+
for await (const task of tasksClient.listTasksAsync({ parent, responseView: 'BASIC', pageSize: 1000 })) {
|
|
216
215
|
const taskNameFull = task.name;
|
|
217
|
-
const taskNameShort = taskNameFull.split('/').pop();
|
|
216
|
+
const taskNameShort = taskNameFull.split('/').pop();
|
|
218
217
|
|
|
219
|
-
// 1.
|
|
220
|
-
// We capture the name AND the hash at the end
|
|
218
|
+
// 1. Root Tasks
|
|
221
219
|
const rootMatch = taskNameShort.match(/^root-(.+)-\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
222
|
-
|
|
223
220
|
if (rootMatch) {
|
|
224
221
|
const [_, kebabName, taskHash] = rootMatch;
|
|
225
222
|
const activeHash = activeComputations.get(kebabName);
|
|
226
|
-
|
|
227
|
-
// DELETE IF:
|
|
228
|
-
// A) Computation removed from manifest (!activeHash)
|
|
229
|
-
// B) Hash mismatch (Old deployment/Stale) (activeHash !== taskHash)
|
|
230
|
-
if (!activeHash || activeHash !== taskHash) {
|
|
231
|
-
tasksToDelete.push(taskNameFull);
|
|
232
|
-
}
|
|
223
|
+
if (!activeHash || activeHash !== taskHash) tasksToDelete.push(taskNameFull);
|
|
233
224
|
continue;
|
|
234
225
|
}
|
|
235
226
|
|
|
236
|
-
// 2.
|
|
237
|
-
// We only delete these if the computation is completely gone.
|
|
238
|
-
// (Timestamps won't match a config hash, so we just check existence)
|
|
227
|
+
// 2. Recovery Tasks
|
|
239
228
|
const recoveryMatch = taskNameShort.match(/^recovery-(.+)-\d{4}-\d{2}-\d{2}-/);
|
|
240
|
-
|
|
241
229
|
if (recoveryMatch) {
|
|
242
230
|
const [_, kebabName] = recoveryMatch;
|
|
243
|
-
if (!activeComputations.has(kebabName))
|
|
244
|
-
tasksToDelete.push(taskNameFull);
|
|
245
|
-
}
|
|
231
|
+
if (!activeComputations.has(kebabName)) tasksToDelete.push(taskNameFull);
|
|
246
232
|
}
|
|
247
233
|
}
|
|
248
234
|
|
|
249
235
|
if (tasksToDelete.length === 0) return 0;
|
|
250
|
-
|
|
251
236
|
console.log(`[Planner] 🗑️ Found ${tasksToDelete.length} stale/orphaned tasks. Deleting...`);
|
|
252
237
|
|
|
253
|
-
// 3. Delete in parallel
|
|
254
238
|
await Promise.all(tasksToDelete.map(name => limit(async () => {
|
|
255
239
|
try {
|
|
256
240
|
await tasksClient.deleteTask({ name });
|
|
257
241
|
deletedCount++;
|
|
258
242
|
} catch (e) {
|
|
259
|
-
|
|
260
|
-
if (e.code !== 5) {
|
|
261
|
-
console.warn(`[Planner] Failed to delete ${name}: ${e.message}`);
|
|
262
|
-
}
|
|
243
|
+
if (e.code !== 5) console.warn(`[Planner] Failed to delete ${name}: ${e.message}`);
|
|
263
244
|
}
|
|
264
245
|
})));
|
|
265
246
|
|
|
266
247
|
} catch (e) {
|
|
267
248
|
console.error(`[Planner] GC Error: ${e.message}`);
|
|
268
249
|
}
|
|
269
|
-
|
|
270
250
|
return deletedCount;
|
|
271
251
|
}
|
|
272
252
|
|
|
@@ -280,10 +260,31 @@ function shouldRunOnDate(schedule, dateObj) {
|
|
|
280
260
|
return true;
|
|
281
261
|
}
|
|
282
262
|
|
|
283
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Calculates the run time.
|
|
265
|
+
* If the Target Date's schedule is in the PAST, it projects execution to the NEXT available window (Today or Tomorrow).
|
|
266
|
+
*/
|
|
267
|
+
function getNextRunWindow(schedule, targetDateObj) {
|
|
284
268
|
const [h, m] = (schedule.time || '02:00').split(':').map(Number);
|
|
285
|
-
|
|
269
|
+
|
|
270
|
+
// 1. Calculate the ideal run time on the TARGET date
|
|
271
|
+
let runTime = new Date(targetDateObj);
|
|
286
272
|
runTime.setUTCHours(h, m, 0, 0);
|
|
273
|
+
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
|
|
276
|
+
// 2. If ideal time is in the past, move it to the *next* occurrence relative to NOW
|
|
277
|
+
if (runTime.getTime() < now) {
|
|
278
|
+
const nextWindow = new Date();
|
|
279
|
+
nextWindow.setUTCHours(h, m, 0, 0);
|
|
280
|
+
|
|
281
|
+
// If today's window has passed, move to tomorrow
|
|
282
|
+
if (nextWindow.getTime() <= now) {
|
|
283
|
+
nextWindow.setUTCDate(nextWindow.getUTCDate() + 1);
|
|
284
|
+
}
|
|
285
|
+
return nextWindow.getTime() / 1000;
|
|
286
|
+
}
|
|
287
|
+
|
|
287
288
|
return runTime.getTime() / 1000;
|
|
288
289
|
}
|
|
289
290
|
|
|
@@ -302,10 +303,10 @@ async function dispatchTasks(tasks) {
|
|
|
302
303
|
|
|
303
304
|
return Promise.all(tasks.map(t => limit(async () => {
|
|
304
305
|
try {
|
|
305
|
-
const name = t.isRecovery
|
|
306
|
+
const name = t.isRecovery
|
|
306
307
|
? `recovery-${toKebab(t.computation)}-${t.targetDate}-${Date.now()}`
|
|
307
308
|
: `root-${toKebab(t.computation)}-${t.targetDate}-${t.configHash}`;
|
|
308
|
-
|
|
309
|
+
|
|
309
310
|
const taskName = `${t.queuePath}/tasks/${name}`;
|
|
310
311
|
|
|
311
312
|
const payload = {
|
|
@@ -331,8 +332,12 @@ async function dispatchTasks(tasks) {
|
|
|
331
332
|
|
|
332
333
|
await tasksClient.createTask({ parent: t.queuePath, task });
|
|
333
334
|
return { status: 'scheduled' };
|
|
335
|
+
|
|
334
336
|
} catch (e) {
|
|
335
|
-
if (e.code === 6 || e.code === 409)
|
|
337
|
+
if (e.code === 6 || e.code === 409) {
|
|
338
|
+
console.warn(`[Planner] Task name collision (Tombstone?): ${t.computation} @ ${t.targetDate}`);
|
|
339
|
+
return { status: 'exists' };
|
|
340
|
+
}
|
|
336
341
|
console.error(`[Planner] Failed task ${t.computation}: ${e.message}`);
|
|
337
342
|
return { status: 'error' };
|
|
338
343
|
}
|