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.1: Reconciler with Active Garbage Collection
3
- * * * 1. Reconcile: Iterates a time window (Past -> Future) to ensure valid tasks exist.
4
- * * 2. Purge: Scans the queue for "Orphans" (tasks for deleted computations) and deletes them.
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
- * REPLACEMENT for planComputations
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: getRunTimeSeconds(root.schedule, dateObj),
124
+ runAtSeconds: runAt,
118
125
  configHash: root.hash,
119
126
  queuePath: getQueuePath(),
120
- reason: `TRIGGERED_BY_${entry.name}_${reason}` // Track what triggered this root
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. Handle ROOT Tasks: root-{kebabName}-{date}-{hash}
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. Handle RECOVERY Tasks: recovery-{kebabName}-{date}-{timestamp}
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
- // Ignore "NOT_FOUND" errors in case of race conditions
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
- function getRunTimeSeconds(schedule, dateObj) {
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
- const runTime = new Date(dateObj);
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) return { status: 'exists' };
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.775",
3
+ "version": "1.0.777",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [