@yellowpanther/shared 1.1.7 → 1.2.1
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 +13 -1
- package/src/config/index.js +8 -0
- package/src/index.js +10 -1
- package/src/queue/articlePublishQueue.js +164 -0
- package/src/queue/imageAlbumPublishQueue.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
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yellowpanther/shared",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
4
7
|
"description": "Reusable shared utilities for YP microservices (e.g., redis, queue, config)",
|
|
5
8
|
"author": "Yellow Panther",
|
|
6
9
|
"main": "src/index.js",
|
|
@@ -18,6 +21,15 @@
|
|
|
18
21
|
"./queue/compressionQueue": "./src/queue/compressionQueue.js",
|
|
19
22
|
"./queue/newsTranslationQueue": "./src/queue/newsTranslationQueue.js",
|
|
20
23
|
"./queue/newsTranslateQueue": "./src/queue/newsTranslateQueue.js",
|
|
24
|
+
"./queue/newsPublishQueue": "./src/queue/newsPublishQueue.js",
|
|
25
|
+
"./queue/articlePublishQueue": "./src/queue/articlePublishQueue.js",
|
|
26
|
+
"./queue/videoPublishQueue": "./src/queue/videoPublishQueue.js",
|
|
27
|
+
"./queue/imagePublishQueue": "./src/queue/imagePublishQueue.js",
|
|
28
|
+
"./queue/imageAlbumPublishQueue": "./src/queue/imageAlbumPublishQueue.js",
|
|
29
|
+
"./queue/pagePublishQueue": "./src/queue/pagePublishQueue.js",
|
|
30
|
+
"./queue/productPublishQueue": "./src/queue/productPublishQueue.js",
|
|
31
|
+
"./queue/quizPublishQueue": "./src/queue/quizPublishQueue.js",
|
|
32
|
+
"./queue/predictPublishQueue": "./src/queue/predictPublishQueue.js",
|
|
21
33
|
"./queue/queueRegistry": "./src/queue/queueRegistry.js",
|
|
22
34
|
"./*": "./src/*"
|
|
23
35
|
},
|
package/src/config/index.js
CHANGED
|
@@ -29,6 +29,9 @@ const envValidation = Joi.object()
|
|
|
29
29
|
LOG_FOLDER: Joi.string().optional(),
|
|
30
30
|
LOG_FILE: Joi.string().optional(),
|
|
31
31
|
LOG_LEVEL: Joi.string().optional(),
|
|
32
|
+
REGISTER_REPEATABLES: Joi.number().optional().default(0),
|
|
33
|
+
DEBUG_DB: Joi.number().optional().default(0),
|
|
34
|
+
DEBUG_LOGGER: Joi.number().optional().default(0),
|
|
32
35
|
|
|
33
36
|
REDIS_HOST: Joi.string().default('127.0.0.1'),
|
|
34
37
|
REDIS_PORT: Joi.number().default(6379),
|
|
@@ -98,6 +101,7 @@ const videoPresets = {
|
|
|
98
101
|
module.exports = {
|
|
99
102
|
name: process.env.APP_NAME || 'ip-cms',
|
|
100
103
|
nodeEnv: envVar.NODE_ENV,
|
|
104
|
+
registerRepeatables: envVar.REGISTER_REPEATABLES || 0,
|
|
101
105
|
port: envVar.PORT,
|
|
102
106
|
dbHost: envVar.DB_HOST,
|
|
103
107
|
dbUser: envVar.DB_USER,
|
|
@@ -125,6 +129,10 @@ module.exports = {
|
|
|
125
129
|
env: process.env.NODE_ENV || 'development',
|
|
126
130
|
port: process.env.PORT || 3000
|
|
127
131
|
},
|
|
132
|
+
debug: {
|
|
133
|
+
logger: envVar.DEBUG_LOGGER || 0,
|
|
134
|
+
db: envVar.DEBUG_DB || 0
|
|
135
|
+
},
|
|
128
136
|
jwt: {
|
|
129
137
|
secret: envVar.JWT_SECRET,
|
|
130
138
|
accessExpirationMinutes: envVar.JWT_ACCESS_EXPIRATION_MINUTES,
|
package/src/index.js
CHANGED
|
@@ -9,5 +9,14 @@ module.exports = {
|
|
|
9
9
|
compressionQueue: require('./queue/compressionQueue'),
|
|
10
10
|
addCompressionJob: require('./queue/compressionQueue').addCompressionJob,
|
|
11
11
|
newsTranslateQueue: require('./queue/newsTranslateQueue'),
|
|
12
|
-
addNewsTranslationJob: require('./queue/newsTranslateQueue').addNewsTranslationJob
|
|
12
|
+
addNewsTranslationJob: require('./queue/newsTranslateQueue').addNewsTranslationJob,
|
|
13
|
+
addNewsPublishJob: require('./queue/newsPublishQueue').addNewsPublishJob,
|
|
14
|
+
addArticlePublishJob: require('./queue/articlePublishQueue').addArticlePublishJob,
|
|
15
|
+
addVideoPublishJob: require('./queue/videoPublishQueue').addVideoPublishJob,
|
|
16
|
+
addImagePublishJob: require('./queue/imagePublishQueue').addImagePublishJob,
|
|
17
|
+
addPagePublishJob: require('./queue/pagePublishQueue').addPagePublishJob,
|
|
18
|
+
addProductPublishJob: require('./queue/productPublishQueue').addProductPublishJob,
|
|
19
|
+
addQuizPublishJob: require('./queue/quizPublishQueue').addQuizPublishJob,
|
|
20
|
+
addPredictionPublishJob: require('./queue/predictionPublishQueue').addPredictionPublishJob,
|
|
21
|
+
addImageAlbumPublishJob: require('./queue/imageAlbumPublishQueue').addImageAlbumPublishJob,
|
|
13
22
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/queue/articlePublishQueue.js
|
|
2
|
+
// One-off *publish* scheduler for article items (BullMQ).
|
|
3
|
+
// Strong upsert: purge any prior job for the same article 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 articlePublishQueue = new Queue('articlePublishQueue', {
|
|
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(article_id) {
|
|
25
|
+
return `article:publish:${article_id}`;
|
|
26
|
+
}
|
|
27
|
+
function versionedJobId(article_id, runAtIso) {
|
|
28
|
+
return `article:publish:${article_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 findPendingArticleJobs(article_id) {
|
|
58
|
+
const states = ['delayed', 'waiting', 'waiting-children', 'active'];
|
|
59
|
+
const jobs = await articlePublishQueue.getJobs(states);
|
|
60
|
+
return jobs.filter(
|
|
61
|
+
(j) => j?.name === 'article:publish' && j?.data?.article_id === String(article_id)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function purgeExistingForArticle(article_id) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
|
|
68
|
+
// Remove any pending/waiting/active jobs for this article
|
|
69
|
+
const pendings = await findPendingArticleJobs(article_id);
|
|
70
|
+
for (const j of pendings) {
|
|
71
|
+
try {
|
|
72
|
+
await j.remove();
|
|
73
|
+
removed++;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (DEBUG) console.warn(`[articlePublishQueue] 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 articlePublishQueue.getJob(stableJobId(article_id));
|
|
81
|
+
if (last) {
|
|
82
|
+
try {
|
|
83
|
+
await last.remove();
|
|
84
|
+
removed++;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (DEBUG) console.warn(`[articlePublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (DEBUG) {
|
|
91
|
+
console.info('[articlePublishQueue] purgeExistingForArticle', {
|
|
92
|
+
article_id: String(article_id),
|
|
93
|
+
removed,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Public API --------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upsert a one-off publish job for an article.
|
|
103
|
+
* - Purges any previous jobs for this article
|
|
104
|
+
* - Uses a versioned jobId by default (or stable if useStableId=true)
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string|number} params.article_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 addArticlePublishJob({ article_id, runAtUtc, extra = {}, useStableId = false }) {
|
|
113
|
+
if (!article_id || !runAtUtc) {
|
|
114
|
+
throw new Error('addArticlePublishJob: article_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 article (across states)
|
|
126
|
+
await purgeExistingForArticle(article_id);
|
|
127
|
+
|
|
128
|
+
// Choose jobId strategy
|
|
129
|
+
const jobId = useStableId ? stableJobId(article_id) : versionedJobId(article_id, runAtIso);
|
|
130
|
+
|
|
131
|
+
const job = await articlePublishQueue.add(
|
|
132
|
+
'article:publish',
|
|
133
|
+
{ article_id: String(article_id), runAtUtc: runAtIso, extra },
|
|
134
|
+
{ jobId, delay: delayMs }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (DEBUG) {
|
|
138
|
+
console.info('[articlePublishQueue] upsert', {
|
|
139
|
+
article_id: String(article_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 article (if present). */
|
|
151
|
+
async function cancelArticlePublishJob(article_id) {
|
|
152
|
+
const removed = await purgeExistingForArticle(article_id);
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
articlePublishQueue,
|
|
158
|
+
addArticlePublishJob,
|
|
159
|
+
cancelArticlePublishJob,
|
|
160
|
+
// exported helpers for tooling/tests
|
|
161
|
+
stableJobId,
|
|
162
|
+
versionedJobId,
|
|
163
|
+
normalizeToUtcDate,
|
|
164
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/queue/imageAlbumPublishQueue.js
|
|
2
|
+
// One-off *publish* scheduler for image album items (BullMQ).
|
|
3
|
+
// Strong upsert: purge any prior job for the same album 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 imageAlbumPublishQueue = new Queue('imageAlbumPublishQueue', {
|
|
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(album_id) {
|
|
25
|
+
return `imageAlbum:publish:${album_id}`;
|
|
26
|
+
}
|
|
27
|
+
function versionedJobId(album_id, runAtIso) {
|
|
28
|
+
return `imageAlbum:publish:${album_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 findPendingAlbumJobs(album_id) {
|
|
58
|
+
const states = ['delayed', 'waiting', 'waiting-children', 'active'];
|
|
59
|
+
const jobs = await imageAlbumPublishQueue.getJobs(states);
|
|
60
|
+
return jobs.filter(
|
|
61
|
+
(j) => j?.name === 'imageAlbum:publish' && j?.data?.album_id === String(album_id)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function purgeExistingForAlbum(album_id) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
|
|
68
|
+
// Remove any pending/waiting/active jobs for this album
|
|
69
|
+
const pendings = await findPendingAlbumJobs(album_id);
|
|
70
|
+
for (const j of pendings) {
|
|
71
|
+
try {
|
|
72
|
+
await j.remove();
|
|
73
|
+
removed++;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (DEBUG) console.warn(`[imageAlbumPublishQueue] 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 imageAlbumPublishQueue.getJob(stableJobId(album_id));
|
|
81
|
+
if (last) {
|
|
82
|
+
try {
|
|
83
|
+
await last.remove();
|
|
84
|
+
removed++;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (DEBUG) console.warn(`[imageAlbumPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (DEBUG) {
|
|
91
|
+
console.info('[imageAlbumPublishQueue] purgeExistingForAlbum', {
|
|
92
|
+
album_id: String(album_id),
|
|
93
|
+
removed,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upsert a one-off publish job for an image album.
|
|
103
|
+
* - Purges any previous jobs for this album
|
|
104
|
+
* - Uses a versioned jobId by default (or stable if useStableId=true)
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string|number} params.album_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 addImageAlbumPublishJob({ album_id, runAtUtc, extra = {}, useStableId = false }) {
|
|
113
|
+
if (!album_id || !runAtUtc) {
|
|
114
|
+
throw new Error('addImageAlbumPublishJob: album_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 album (across states)
|
|
126
|
+
await purgeExistingForAlbum(album_id);
|
|
127
|
+
|
|
128
|
+
// Choose jobId strategy
|
|
129
|
+
const jobId = useStableId ? stableJobId(album_id) : versionedJobId(album_id, runAtIso);
|
|
130
|
+
|
|
131
|
+
const job = await imageAlbumPublishQueue.add(
|
|
132
|
+
'imageAlbum:publish',
|
|
133
|
+
{ album_id: String(album_id), runAtUtc: runAtIso, extra },
|
|
134
|
+
{ jobId, delay: delayMs }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (DEBUG) {
|
|
138
|
+
console.info('[imageAlbumPublishQueue] upsert', {
|
|
139
|
+
album_id: String(album_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 image album (if present). */
|
|
151
|
+
async function cancelImageAlbumPublishJob(album_id) {
|
|
152
|
+
const removed = await purgeExistingForAlbum(album_id);
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
imageAlbumPublishQueue,
|
|
158
|
+
addImageAlbumPublishJob,
|
|
159
|
+
cancelImageAlbumPublishJob,
|
|
160
|
+
// helpers for tooling/tests
|
|
161
|
+
stableJobId,
|
|
162
|
+
versionedJobId,
|
|
163
|
+
normalizeToUtcDate,
|
|
164
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/queue/imagePublishQueue.js
|
|
2
|
+
// One-off *publish* scheduler for image items (BullMQ).
|
|
3
|
+
// Strong upsert: purge any prior job for the same image 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 imagePublishQueue = new Queue('imagePublishQueue', {
|
|
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(image_id) {
|
|
25
|
+
return `image:publish:${image_id}`;
|
|
26
|
+
}
|
|
27
|
+
function versionedJobId(image_id, runAtIso) {
|
|
28
|
+
return `image:publish:${image_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 findPendingImageJobs(image_id) {
|
|
58
|
+
const states = ['delayed', 'waiting', 'waiting-children', 'active'];
|
|
59
|
+
const jobs = await imagePublishQueue.getJobs(states);
|
|
60
|
+
return jobs.filter(
|
|
61
|
+
(j) => j?.name === 'image:publish' && j?.data?.image_id === String(image_id)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function purgeExistingForImage(image_id) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
|
|
68
|
+
// Remove any pending/waiting/active jobs for this image
|
|
69
|
+
const pendings = await findPendingImageJobs(image_id);
|
|
70
|
+
for (const j of pendings) {
|
|
71
|
+
try {
|
|
72
|
+
await j.remove();
|
|
73
|
+
removed++;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (DEBUG) console.warn(`[imagePublishQueue] 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 imagePublishQueue.getJob(stableJobId(image_id));
|
|
81
|
+
if (last) {
|
|
82
|
+
try {
|
|
83
|
+
await last.remove();
|
|
84
|
+
removed++;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (DEBUG) console.warn(`[imagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (DEBUG) {
|
|
91
|
+
console.info('[imagePublishQueue] purgeExistingForImage', {
|
|
92
|
+
image_id: String(image_id),
|
|
93
|
+
removed,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upsert a one-off publish job for an image.
|
|
103
|
+
* - Purges any previous jobs for this image
|
|
104
|
+
* - Uses a versioned jobId by default (or stable if useStableId=true)
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string|number} params.image_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 addImagePublishJob({ image_id, runAtUtc, extra = {}, useStableId = false }) {
|
|
113
|
+
if (!image_id || !runAtUtc) {
|
|
114
|
+
throw new Error('addImagePublishJob: image_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 image (across states)
|
|
126
|
+
await purgeExistingForImage(image_id);
|
|
127
|
+
|
|
128
|
+
// Choose jobId strategy
|
|
129
|
+
const jobId = useStableId ? stableJobId(image_id) : versionedJobId(image_id, runAtIso);
|
|
130
|
+
|
|
131
|
+
const job = await imagePublishQueue.add(
|
|
132
|
+
'image:publish',
|
|
133
|
+
{ image_id: String(image_id), runAtUtc: runAtIso, extra },
|
|
134
|
+
{ jobId, delay: delayMs }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (DEBUG) {
|
|
138
|
+
console.info('[imagePublishQueue] upsert', {
|
|
139
|
+
image_id: String(image_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 image (if present). */
|
|
151
|
+
async function cancelImagePublishJob(image_id) {
|
|
152
|
+
const removed = await purgeExistingForImage(image_id);
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
imagePublishQueue,
|
|
158
|
+
addImagePublishJob,
|
|
159
|
+
cancelImagePublishJob,
|
|
160
|
+
// helpers for tooling/tests
|
|
161
|
+
stableJobId,
|
|
162
|
+
versionedJobId,
|
|
163
|
+
normalizeToUtcDate,
|
|
164
|
+
};
|