UNPKG

apostrophe

Version:
455 lines (432 loc) • 16.6 kB
const { stripIndent } = require('common-tags'); // The `@apostrophecms/job` module runs long-running jobs in response // to user actions. Batch operations on pieces are a good example. // // The `@apostrophecms/job` module makes it simple to implement // progress display, and avoid server timeouts during long operations. // // See the `run` method for the easiest way to use this module. // The `run` method allows you to implement routes that are designed // to be paired with the `progress` method of this module on // the browser side. All you need to do is make sure the // IDs are posted to your route and then write a function that // can carry out the operation on one id. // // If you just want to add a new batch operation for pieces, // see the examples already in `@apostrophecms/piece-type` (e.g., // `batchSimpleRoute`). You don't need to go directly to this module for that // case. // // If your operation doesn't break down neatly into repeated operations // on single documents, look into calling the `start` method and friends // directly from your code. // // The `@apostrophecms/job` module DOES NOT have anything to do with // cron jobs or command line tasks. module.exports = { options: { collectionName: 'aposJobs' }, icons: { 'database-export-icon': 'DatabaseExport' }, async init(self) { await self.ensureCollection(); self.enableBrowserData(); }, apiRoutes(self) { return { post: { async progress(req) { self.apos.util.warnDev(stripIndent` The @apostrophecms/job module's "/progress" route is deprecated. Use the RESTful registered "action" route on the module instead. `); return {}; } } }; }, restApiRoutes (self) { return { async getOne(req, _id) { if (!self.apos.permission.can(req, 'view-draft')) { throw self.apos.error('notfound'); } const jobId = self.apos.launder.id(_id); const job = await self.db.findOne({ _id: jobId }); if (!job) { throw self.apos.error('notfound'); } job.percentage = !job.total ? 0 : (job.processed / job.total * 100).toFixed(2); return job; } }; }, methods(self) { return { // Starts and supervises a long-running job such as a // batch operation on pieces. Call it to implement an API route // that runs a job involving carrying out the same action // repetitively on many things. If your job doesn't look like // that, check out `run` instead. // // The `ids` to be processed should be provided via `req.body.ids`. // // Pass `req` and an async `change` function that accepts `(req, id)`, // performs the modification on that one id and throws an error if // necessary; this is recorded as a bad item in the job, it does // not stop the job. `change` may optionally return a result object. // // This method will return an object with a `jobId` identifier as soon as // the job is ready to start, and the actual job will continue in the // background afterwards. You can pass `jobId` to the `progress` API route // of this module as `_id` on the request body to get job status info. // // The actual work continues in background after this method returns. // // Jobs run in this way support the stop operation. They do not // currently support the cancel (undo/rollback) operation. // // The `batchSimple` method of `@apostrophecms/piece-type` is an even // simpler wrapper for this method if you are implementing a batch // operation on a single type of piece. // // Notification messages should be included on a `req.body.messages` // object. See `triggerNotification for details`. async runBatch(req, ids, change, options = {}) { let job; let notification; const total = ids.length; const results = {}; const res = req.res; try { // sends a response with a jobId to the browser job = await self.start(options); self.setTotal(job, ids.length); // Runs after response is already sent run(); // Trigger the "in progress" notification. notification = await self.triggerNotification(req, 'progress', { // It's only relevant to pass a job ID to the notification if // the notification will show progress. Without a total number we // can't show progress. jobId: total && job._id, ids, action: options.action, docTypes: options.docTypes }); return { jobId: job._id }; } catch (err) { self.apos.util.error(err); if (!job) { return res.status(500).send('error'); } try { return await self.end(job, false); } catch (err) { // Not a lot we can do about this since we already // stopped talking to the user self.apos.util.error(err); } } async function run() { let good = false; const promises = []; try { for (const id of ids) { try { const result = await change(req, id); promises.push(self.success(job)); results[id] = result; } catch (err) { promises.push(self.failure(job)); } } good = true; } finally { await self.end(job, good, results); // Wait for increments to be updated in DB await Promise.allSettled(promises); // Trigger the completed notification. await self.triggerNotification(req, 'completed', { contextId: job._id, dismiss: true }); // Dismiss the progress notification. It will delay 4 seconds // because "completed" notification will dismiss in 5 and we want // to maintain the feeling of process order for users. await self.apos.notification.dismiss(req, notification.noteId, 4000); } } }, // Similar to `runBatch`, this method Starts and supervises a // long-running job, however unlike `runBatch` the `doTheWork` function // provided is invoked just once, and when it completes the job is over. // This is not the way to implement a batch operation on pieces; see the // `batchSimple` method of that module. // // The `doTheWork` function receives `(req, reporting)` and may // optionally invoke `reporting.success()` and `reporting.failure()` to // update the progress and error counters, and `reporting.setTotal()` to // indicate the total number of counts expected so a progress meter can be // rendered. This is optional and an indication of progress is still // displayed without it. // // `reporting.setResults(object)` may also be called to pass a // results object, which will be available on the finished job document. // // This method will return an object with a `jobId` identifier as soon as // the job is ready to start, and the actual job will continue in the // background afterwards. You can pass `jobId` to the `progress` API route // of this module as `_id` on the request body to get job status info. // // Notification messages should be included on a `req.body.messages` // object. See `triggerNotification for details`. async run(req, doTheWork, options = {}) { const res = req.res; let job; let total; try { job = await self.start(options); const notification = await self.triggerNotification(req, 'progress', { jobId: job._id, ids: options.ids, action: options.action, docTypes: options.docTypes }); run({ notificationId: notification.noteId }); return { jobId: job._id }; } catch (err) { self.apos.util.error(err); if (!job) { // If we never made a job, then we never responded // otherwise we already stopped talking to the user return res.status(500).send('error'); } } async function run(info) { let results; let good = false; const promises = []; try { await doTheWork(req, { success (n) { n = n || 1; const result = self.success(job, n); promises.push(result); return result; }, failure (n) { n = n || 1; const result = self.failure(job, n); promises.push(result); return result; }, setTotal (n) { total = n; return self.setTotal(job, n); }, setResults (_results) { results = _results; } }, info); good = true; } finally { await self.end(job, good, results); // Wait for increments to be updated in DB await Promise.allSettled(promises); // Trigger the completed notification. await self.triggerNotification(req, 'completed', { contextId: job._id, count: total, dismiss: true }, results); // Dismiss the progress notification. It will delay 4 seconds // because "completed" notification will dismiss in 5 and we want // to maintain the feeling of process order for users. await self.apos.notification.dismiss(req, info.notificationId, 4000); } } }, // Job notification messages are passed to `triggerNotifications` // through `req.body.messages` via `run` and `runBatch`. The // `req.body.messages` object can include `progress` and `completed` // properties, which will be used for notifications when the job starts // (`progress`) and ends (`completed`). Those messages can include the // following interpolation keys: // - {{ type }}: The doc type, as passed to the job on req.body.type // - {{ count }}: The count of total document IDs in the req.body._ids // array // No messages are required, but they provide helpful information to // end users. async triggerNotification(req, stage, options = {}, results) { if (!req?.body?.messages?.[stage]) { return {}; } const event = req.body.messages.resultsEventName && results ? { name: req.body.messages.resultsEventName, data: { ...results } } : null; const { good, bad, processed, total } = stage === 'completed' && options.contextId ? await self.db.findOne({ _id: options.contextId }) : {}; let message = req.body.messages[stage]; if (stage === 'completed' && req.body.messages.failed && bad > 0 && good === 0) { message = req.body.messages.failed; } else if (stage === 'completed' && req.body.messages.completedWithFailures && bad > 0 && good > 0) { message = req.body.messages.completedWithFailures; } return self.apos.notification.trigger(req, message, { interpolate: { bad, count: options.count || (req.body._ids && req.body._ids.length), good, processed, total, type: req.t(req.body.type) || req.t('apostrophe:document') }, context: { _id: options.contextId }, dismiss: options.dismiss, job: { // NOTE: if options.jobId is present, the notification will be a progress bar _id: options.jobId, action: options.action, ids: options.ids, docTypes: options.docTypes }, event, classes: options.classes, icon: req.body.messages.icon || 'database-export-icon', type: options.type || 'success' }); }, // Start tracking a long-running job. Called by routes // that require progress display and/or the ability to take longer // than the server might permit a single HTTP request to last. // *You usually won't call this yourself. The easy way is usually // to call the `runBatch` or `run` methods.* // // On success this method returns a `job` object. // You can then invoke the `setTotal`, `success`, `error`, // and `end` methods of the job object. `start` and `end` // are async functions. // // Once you successfully call `start`, you *must* eventually call // `end` with a `job` object. In addition, // you *may* call `success(job)` and `error(job)` any number of times // to indicate the success or failure of one "row" or other item // processed, and you *may* call `setTotal(job, n)` to indicate // how many rows to expect for better progress display. async start(options) { const job = { _id: self.apos.util.generateId(), good: 0, bad: 0, processed: 0, status: 'running', ended: false, when: new Date() }; const context = { _id: job._id, options }; await self.db.insertOne(job); return context; }, // Call this to report that n items were good // (successfully processed). // // If the second argument is completely omitted, // the default is `1`. async success(job, n) { n = n === undefined ? 1 : n; try { await self.db.updateOne({ _id: job._id }, { $inc: { good: n, processed: n } }); } catch (err) { self.apos.util.error(err); } }, // Call this to report that n items were bad // (not successfully processed). // // If the second argument is completely omitted, // the default is 1. async failure(job, n) { n = n === undefined ? 1 : n; try { await self.db.updateOne({ _id: job._id }, { $inc: { bad: n, processed: n } }); } catch (err) { self.apos.util.error(err); }; }, // Call this to indicate the total number // of items expected. Until and unless this is called // a different type of progress display is used. // If you do call this, the total of all calls // to success() and error() should not exceed it. // // It is OK not to call this, however the progress // display will be less informative. // // No promise is returned as this method just updates // the job tracking information in the background. setTotal(job, total) { self.db.updateOne({ _id: job._id }, { $set: { total } }, function (err) { if (err) { self.apos.util.error(err); } }); }, // Mark the given job as ended. If `success` // is true the job is reported as an overall // success, if `failed` is true the job // is reported as an overall failure. // Either way the user can still see the // count of "good" and "bad" items. // // If the results parameter is not omitted entirely, // it is added to the job object in the database // as the `results` property. async end(job, success, results) { // context object gets an update to avoid a // race condition with checkStopped job.ended = true; return self.db.updateOne({ _id: job._id }, { $set: { ended: true, status: success ? 'completed' : 'failed', results } }); }, async ensureCollection() { self.db = await self.apos.db.collection(self.options.collectionName); }, getBrowserData(req) { return { action: self.action }; } }; } };