@yellowpanther/shared 1.1.7 → 1.2.0
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.
- package/package.json +12 -1
- package/src/config/index.js +8 -0
- package/src/index.js +10 -1
- package/src/queue/articlePublishQueue.js +164 -0
- package/src/queue/imagePublishQueue.js +164 -0
- package/src/queue/newsPublishQueue.js +164 -0
- package/src/queue/pagePublishQueue.js +164 -0
- package/src/queue/predictionPublishQueue.js +164 -0
- package/src/queue/productPublishQueue.js +164 -0
- package/src/queue/queueRegistry.js +40 -0
- package/src/queue/quizPublishQueue.js +164 -0
- package/src/queue/videoPublishQueue.js +164 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/queue/quizPublishQueue.js
|
|
2
|
+
// One-off *publish* scheduler for quiz items (BullMQ).
|
|
3
|
+
// Strong upsert: purge any prior job for the same quiz before adding a new delayed job.
|
|
4
|
+
|
|
5
|
+
const { Queue } = require('bullmq');
|
|
6
|
+
const redisClient = require('../redis/redisClient');
|
|
7
|
+
// const logger = require('../logger'); // uncomment if you have a shared logger
|
|
8
|
+
|
|
9
|
+
const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
|
|
10
|
+
const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
|
|
11
|
+
|
|
12
|
+
const quizPublishQueue = new Queue('quizPublishQueue', {
|
|
13
|
+
connection: redisClient,
|
|
14
|
+
defaultJobOptions: {
|
|
15
|
+
attempts: 5,
|
|
16
|
+
backoff: { type: 'exponential', delay: 2000 },
|
|
17
|
+
removeOnComplete: 500,
|
|
18
|
+
removeOnFail: 500,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── ID helpers ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function stableJobId(quiz_id) {
|
|
25
|
+
return `quiz:publish:${quiz_id}`;
|
|
26
|
+
}
|
|
27
|
+
function versionedJobId(quiz_id, runAtIso) {
|
|
28
|
+
return `quiz:publish:${quiz_id}:${runAtIso}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Time helpers ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
|
|
34
|
+
function normalizeToUtcDate(input) {
|
|
35
|
+
if (input instanceof Date) return new Date(input.getTime());
|
|
36
|
+
if (typeof input === 'number') return new Date(input); // epoch ms
|
|
37
|
+
|
|
38
|
+
if (typeof input === 'string') {
|
|
39
|
+
// Has timezone (Z or ±hh:mm)
|
|
40
|
+
if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
|
|
41
|
+
const d = new Date(input);
|
|
42
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
// No timezone => treat as UTC
|
|
46
|
+
const s = input.trim().replace(' ', 'T');
|
|
47
|
+
const d = new Date(`${s}Z`);
|
|
48
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
|
|
49
|
+
return d;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error(`Unsupported runAtUtc type: ${typeof input}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Purge helpers ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async function findPendingQuizJobs(quiz_id) {
|
|
58
|
+
const states = ['delayed', 'waiting', 'waiting-children', 'active'];
|
|
59
|
+
const jobs = await quizPublishQueue.getJobs(states);
|
|
60
|
+
return jobs.filter(
|
|
61
|
+
(j) => j?.name === 'quiz:publish' && j?.data?.quiz_id === String(quiz_id)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function purgeExistingForQuiz(quiz_id) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
|
|
68
|
+
// Remove any pending/waiting/active jobs for this quiz
|
|
69
|
+
const pendings = await findPendingQuizJobs(quiz_id);
|
|
70
|
+
for (const j of pendings) {
|
|
71
|
+
try {
|
|
72
|
+
await j.remove();
|
|
73
|
+
removed++;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (DEBUG) console.warn(`[quizPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Also remove a leftover stable job if present
|
|
80
|
+
const last = await quizPublishQueue.getJob(stableJobId(quiz_id));
|
|
81
|
+
if (last) {
|
|
82
|
+
try {
|
|
83
|
+
await last.remove();
|
|
84
|
+
removed++;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (DEBUG) console.warn(`[quizPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (DEBUG) {
|
|
91
|
+
console.info('[quizPublishQueue] purgeExistingForQuiz', {
|
|
92
|
+
quiz_id: String(quiz_id),
|
|
93
|
+
removed,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upsert a one-off publish job for a quiz.
|
|
103
|
+
* - Purges any previous jobs for this quiz
|
|
104
|
+
* - Uses a versioned jobId by default (or stable if useStableId=true)
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string|number} params.quiz_id
|
|
108
|
+
* @param {string|number|Date} params.runAtUtc - ISO string, epoch ms, or Date (UTC)
|
|
109
|
+
* @param {object} [params.extra]
|
|
110
|
+
* @param {boolean} [params.useStableId=false] - if true, uses stable jobId
|
|
111
|
+
*/
|
|
112
|
+
async function addQuizPublishJob({ quiz_id, runAtUtc, extra = {}, useStableId = false }) {
|
|
113
|
+
if (!quiz_id || !runAtUtc) {
|
|
114
|
+
throw new Error('addQuizPublishJob: quiz_id and runAtUtc are required');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const runAt = normalizeToUtcDate(runAtUtc);
|
|
118
|
+
const runAtIso = runAt.toISOString();
|
|
119
|
+
|
|
120
|
+
// Drift guard to avoid immediate fire due to ms skew
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
let delayMs = runAt.getTime() - now;
|
|
123
|
+
if (delayMs < DRIFT_MS) delayMs = Math.max(0, DRIFT_MS);
|
|
124
|
+
|
|
125
|
+
// Purge any prior jobs for this quiz (across states)
|
|
126
|
+
await purgeExistingForQuiz(quiz_id);
|
|
127
|
+
|
|
128
|
+
// Choose jobId strategy
|
|
129
|
+
const jobId = useStableId ? stableJobId(quiz_id) : versionedJobId(quiz_id, runAtIso);
|
|
130
|
+
|
|
131
|
+
const job = await quizPublishQueue.add(
|
|
132
|
+
'quiz:publish',
|
|
133
|
+
{ quiz_id: String(quiz_id), runAtUtc: runAtIso, extra },
|
|
134
|
+
{ jobId, delay: delayMs }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (DEBUG) {
|
|
138
|
+
console.info('[quizPublishQueue] upsert', {
|
|
139
|
+
quiz_id: String(quiz_id),
|
|
140
|
+
jobId,
|
|
141
|
+
runAtIso,
|
|
142
|
+
delayMs,
|
|
143
|
+
nowIso: new Date(now).toISOString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return job;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Cancel any scheduled publish for a given quiz (if present). */
|
|
151
|
+
async function cancelQuizPublishJob(quiz_id) {
|
|
152
|
+
const removed = await purgeExistingForQuiz(quiz_id);
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
quizPublishQueue,
|
|
158
|
+
addQuizPublishJob,
|
|
159
|
+
cancelQuizPublishJob,
|
|
160
|
+
// helpers for tooling/tests
|
|
161
|
+
stableJobId,
|
|
162
|
+
versionedJobId,
|
|
163
|
+
normalizeToUtcDate,
|
|
164
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/queue/videoPublishQueue.js
|
|
2
|
+
// One-off *publish* scheduler for video items (BullMQ).
|
|
3
|
+
// Strong upsert: purge any prior job for the same video before adding a new delayed job.
|
|
4
|
+
|
|
5
|
+
const { Queue } = require('bullmq');
|
|
6
|
+
const redisClient = require('../redis/redisClient');
|
|
7
|
+
// const logger = require('../logger'); // uncomment if you have a shared logger
|
|
8
|
+
|
|
9
|
+
const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
|
|
10
|
+
const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
|
|
11
|
+
|
|
12
|
+
const videoPublishQueue = new Queue('videoPublishQueue', {
|
|
13
|
+
connection: redisClient,
|
|
14
|
+
defaultJobOptions: {
|
|
15
|
+
attempts: 5,
|
|
16
|
+
backoff: { type: 'exponential', delay: 2000 },
|
|
17
|
+
removeOnComplete: 500,
|
|
18
|
+
removeOnFail: 500,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── ID helpers ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function stableJobId(video_id) {
|
|
25
|
+
return `video:publish:${video_id}`;
|
|
26
|
+
}
|
|
27
|
+
function versionedJobId(video_id, runAtIso) {
|
|
28
|
+
return `video:publish:${video_id}:${runAtIso}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Time helpers ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
|
|
34
|
+
function normalizeToUtcDate(input) {
|
|
35
|
+
if (input instanceof Date) return new Date(input.getTime());
|
|
36
|
+
if (typeof input === 'number') return new Date(input); // epoch ms
|
|
37
|
+
|
|
38
|
+
if (typeof input === 'string') {
|
|
39
|
+
// Has timezone (Z or ±hh:mm)
|
|
40
|
+
if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
|
|
41
|
+
const d = new Date(input);
|
|
42
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
// No timezone => treat as UTC
|
|
46
|
+
const s = input.trim().replace(' ', 'T');
|
|
47
|
+
const d = new Date(`${s}Z`);
|
|
48
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
|
|
49
|
+
return d;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error(`Unsupported runAtUtc type: ${typeof input}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Purge helpers ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async function findPendingVideoJobs(video_id) {
|
|
58
|
+
const states = ['delayed', 'waiting', 'waiting-children', 'active'];
|
|
59
|
+
const jobs = await videoPublishQueue.getJobs(states);
|
|
60
|
+
return jobs.filter(
|
|
61
|
+
(j) => j?.name === 'video:publish' && j?.data?.video_id === String(video_id)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function purgeExistingForVideo(video_id) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
|
|
68
|
+
// Remove any pending/waiting/active jobs for this video
|
|
69
|
+
const pendings = await findPendingVideoJobs(video_id);
|
|
70
|
+
for (const j of pendings) {
|
|
71
|
+
try {
|
|
72
|
+
await j.remove();
|
|
73
|
+
removed++;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (DEBUG) console.warn(`[videoPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Also remove a leftover stable job if present
|
|
80
|
+
const last = await videoPublishQueue.getJob(stableJobId(video_id));
|
|
81
|
+
if (last) {
|
|
82
|
+
try {
|
|
83
|
+
await last.remove();
|
|
84
|
+
removed++;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (DEBUG) console.warn(`[videoPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (DEBUG) {
|
|
91
|
+
console.info('[videoPublishQueue] purgeExistingForVideo', {
|
|
92
|
+
video_id: String(video_id),
|
|
93
|
+
removed,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upsert a one-off publish job for a video.
|
|
103
|
+
* - Purges any previous jobs for this video
|
|
104
|
+
* - Uses a versioned jobId by default (or stable if useStableId=true)
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string|number} params.video_id
|
|
108
|
+
* @param {string|number|Date} params.runAtUtc - ISO string, epoch ms, or Date (UTC)
|
|
109
|
+
* @param {object} [params.extra]
|
|
110
|
+
* @param {boolean} [params.useStableId=false] - if true, uses stable jobId
|
|
111
|
+
*/
|
|
112
|
+
async function addVideoPublishJob({ video_id, runAtUtc, extra = {}, useStableId = false }) {
|
|
113
|
+
if (!video_id || !runAtUtc) {
|
|
114
|
+
throw new Error('addVideoPublishJob: video_id and runAtUtc are required');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const runAt = normalizeToUtcDate(runAtUtc);
|
|
118
|
+
const runAtIso = runAt.toISOString();
|
|
119
|
+
|
|
120
|
+
// drift guard to avoid immediate fire due to ms skew
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
let delayMs = runAt.getTime() - now;
|
|
123
|
+
if (delayMs < DRIFT_MS) delayMs = Math.max(0, DRIFT_MS);
|
|
124
|
+
|
|
125
|
+
// Purge any prior jobs for this video (across states)
|
|
126
|
+
await purgeExistingForVideo(video_id);
|
|
127
|
+
|
|
128
|
+
// Choose jobId strategy
|
|
129
|
+
const jobId = useStableId ? stableJobId(video_id) : versionedJobId(video_id, runAtIso);
|
|
130
|
+
|
|
131
|
+
const job = await videoPublishQueue.add(
|
|
132
|
+
'video:publish',
|
|
133
|
+
{ video_id: String(video_id), runAtUtc: runAtIso, extra },
|
|
134
|
+
{ jobId, delay: delayMs }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (DEBUG) {
|
|
138
|
+
console.info('[videoPublishQueue] upsert', {
|
|
139
|
+
video_id: String(video_id),
|
|
140
|
+
jobId,
|
|
141
|
+
runAtIso,
|
|
142
|
+
delayMs,
|
|
143
|
+
nowIso: new Date(now).toISOString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return job;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Cancel any scheduled publish for a given video (if present). */
|
|
151
|
+
async function cancelVideoPublishJob(video_id) {
|
|
152
|
+
const removed = await purgeExistingForVideo(video_id);
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
videoPublishQueue,
|
|
158
|
+
addVideoPublishJob,
|
|
159
|
+
cancelVideoPublishJob,
|
|
160
|
+
// helpers for tooling/tests
|
|
161
|
+
stableJobId,
|
|
162
|
+
versionedJobId,
|
|
163
|
+
normalizeToUtcDate,
|
|
164
|
+
};
|