├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── Models ├── Queue.js └── Worker.js ├── README.md ├── config ├── Database.js └── config.js ├── docs ├── easy-os-background-tasks-in-react-native.md ├── easy-os-background-tasks-in-react-native.png └── logo.png ├── index.js ├── package.json ├── tests ├── Queue.test.js └── Worker.test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "parser": "babel-eslint", 7 | "extends": [ 8 | "eslint:recommended" 9 | ], 10 | "parserOptions": { 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "indent": [ 15 | "error", 16 | 2, 17 | { "SwitchCase": 1 } 18 | ], 19 | "brace-style": [ 20 | "error", 21 | "1tbs", 22 | { "allowSingleLine": true } 23 | ], 24 | "linebreak-style": [ 25 | "error", 26 | "unix" 27 | ], 28 | "quotes": [ 29 | "error", 30 | "single" 31 | ], 32 | "semi": [ 33 | "error", 34 | "always" 35 | ], 36 | "no-var": "error" 37 | } 38 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /.idea 4 | /reactNativeQueue.realm* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # Travis CI config 4 | # https://docs.travis-ci.com/user/customizing-the-build/ 5 | # 6 | 7 | env: 8 | - COVERALLS_ENV=production 9 | 10 | branches: 11 | only: 12 | - master 13 | 14 | language: node_js 15 | 16 | node_js: 17 | - "7" 18 | 19 | cache: 20 | directories: 21 | - "node_modules" 22 | 23 | script: 24 | - npm run lint 25 | - npm test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Reid Mayo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Models/Queue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Queue Model 4 | * 5 | * Queue Job Realm Schema defined in ../config/Database 6 | * 7 | */ 8 | 9 | import Database from '../config/Database'; 10 | import uuid from 'react-native-uuid'; 11 | import Worker from './Worker'; 12 | import promiseReflect from 'promise-reflect'; 13 | 14 | 15 | export class Queue { 16 | 17 | /** 18 | * 19 | * Set initial class properties. 20 | * 21 | * @constructor 22 | */ 23 | constructor() { 24 | this.realm = null; 25 | this.worker = new Worker(); 26 | this.status = 'inactive'; 27 | } 28 | 29 | /** 30 | * 31 | * Initializes the queue by connecting to Realm database. 32 | * 33 | */ 34 | async init() { 35 | if (this.realm === null) { 36 | this.realm = await Database.getRealmInstance(); 37 | } 38 | } 39 | 40 | /** 41 | * 42 | * Add a worker function to the queue. 43 | * 44 | * Worker will be called to execute jobs associated with jobName. 45 | * 46 | * Worker function will receive job id and job payload as parameters. 47 | * 48 | * Example: 49 | * 50 | * function exampleJobWorker(id, payload) { 51 | * console.log(id); // UUID of job. 52 | * console.log(payload); // Payload of data related to job. 53 | * } 54 | * 55 | * @param jobName {string} - Name associated with jobs assigned to this worker. 56 | * @param worker {function} - The worker function that will execute jobs. 57 | * @param options {object} - Worker options. See README.md for worker options info. 58 | */ 59 | addWorker(jobName, worker, options = {}) { 60 | this.worker.addWorker(jobName, worker, options); 61 | } 62 | 63 | /** 64 | * 65 | * Delete worker function from queue. 66 | * 67 | * @param jobName {string} - Name associated with jobs assigned to this worker. 68 | */ 69 | removeWorker(jobName) { 70 | this.worker.removeWorker(jobName); 71 | } 72 | 73 | /** 74 | * 75 | * Creates a new job and adds it to queue. 76 | * 77 | * Queue will automatically start processing unless startQueue param is set to false. 78 | * 79 | * @param name {string} - Name associated with job. The worker function assigned to this name will be used to execute this job. 80 | * @param payload {object} - Object of arbitrary data to be passed into worker function when job executes. 81 | * @param options {object} - Job related options like timeout etc. See README.md for job options info. 82 | * @param startQueue - {boolean} - Whether or not to immediately begin prcessing queue. If false queue.start() must be manually called. 83 | */ 84 | createJob(name, payload = {}, options = {}, startQueue = true) { 85 | 86 | if (!name) { 87 | throw new Error('Job name must be supplied.'); 88 | } 89 | 90 | // Validate options 91 | if (options.timeout < 0 || options.attempts < 0) { 92 | throw new Error('Invalid job option.'); 93 | } 94 | 95 | this.realm.write(() => { 96 | 97 | this.realm.create('Job', { 98 | id: uuid.v4(), 99 | name, 100 | payload: JSON.stringify(payload), 101 | data: JSON.stringify({ 102 | attempts: options.attempts || 1 103 | }), 104 | priority: options.priority || 0, 105 | active: false, 106 | timeout: (options.timeout >= 0) ? options.timeout : 25000, 107 | created: new Date(), 108 | failed: null 109 | }); 110 | 111 | }); 112 | 113 | // Start queue on job creation if it isn't running by default. 114 | if (startQueue && this.status == 'inactive') { 115 | this.start(); 116 | } 117 | 118 | } 119 | 120 | /** 121 | * 122 | * Start processing the queue. 123 | * 124 | * If queue was not started automatically during queue.createJob(), this 125 | * method should be used to manually start the queue. 126 | * 127 | * If queue.start() is called again when queue is already running, 128 | * queue.start() will return early with a false boolean value instead 129 | * of running multiple queue processing loops concurrently. 130 | * 131 | * Lifespan can be passed to start() in order to run the queue for a specific amount of time before stopping. 132 | * This is useful, as an example, for OS background tasks which typically are time limited. 133 | * 134 | * NOTE: If lifespan is set, only jobs with a timeout property at least 500ms less than remaining lifespan will be processed 135 | * during queue processing lifespan. This is to buffer for the small amount of time required to query Realm for suitable 136 | * jobs, and to mark such jobs as complete or failed when job finishes processing. 137 | * 138 | * IMPORTANT: Jobs with timeout set to 0 that run indefinitely will not be processed if the queue is running with a lifespan. 139 | * 140 | * @param lifespan {number} - If lifespan is passed, the queue will start up and run for lifespan ms, then queue will be stopped. 141 | * @return {boolean|undefined} - False if queue is already started. Otherwise nothing is returned when queue finishes processing. 142 | */ 143 | async start(lifespan = 0) { 144 | 145 | // If queue is already running, don't fire up concurrent loop. 146 | if (this.status == 'active') { 147 | return false; 148 | } 149 | 150 | this.status = 'active'; 151 | 152 | // Get jobs to process 153 | const startTime = Date.now(); 154 | let lifespanRemaining = null; 155 | let concurrentJobs = []; 156 | 157 | if (lifespan !== 0) { 158 | lifespanRemaining = lifespan - (Date.now() - startTime); 159 | lifespanRemaining = (lifespanRemaining === 0) ? -1 : lifespanRemaining; // Handle exactly zero lifespan remaining edge case. 160 | concurrentJobs = await this.getConcurrentJobs(lifespanRemaining); 161 | } else { 162 | concurrentJobs = await this.getConcurrentJobs(); 163 | } 164 | 165 | while (this.status == 'active' && concurrentJobs.length) { 166 | 167 | // Loop over jobs and process them concurrently. 168 | const processingJobs = concurrentJobs.map( job => { 169 | return this.processJob(job); 170 | }); 171 | 172 | // Promise Reflect ensures all processingJobs resolve so 173 | // we don't break await early if one of the jobs fails. 174 | await Promise.all(processingJobs.map(promiseReflect)); 175 | 176 | // Get next batch of jobs. 177 | if (lifespan !== 0) { 178 | lifespanRemaining = lifespan - (Date.now() - startTime); 179 | lifespanRemaining = (lifespanRemaining === 0) ? -1 : lifespanRemaining; // Handle exactly zero lifespan remaining edge case. 180 | concurrentJobs = await this.getConcurrentJobs(lifespanRemaining); 181 | } else { 182 | concurrentJobs = await this.getConcurrentJobs(); 183 | } 184 | 185 | } 186 | 187 | this.status = 'inactive'; 188 | 189 | } 190 | 191 | /** 192 | * 193 | * Stop processing queue. 194 | * 195 | * If queue.stop() is called, queue will stop processing until 196 | * queue is restarted by either queue.createJob() or queue.start(). 197 | * 198 | */ 199 | stop() { 200 | this.status = 'inactive'; 201 | } 202 | 203 | /** 204 | * 205 | * Get a collection of all the jobs in the queue. 206 | * 207 | * @param sync {boolean} - This should be true if you want to guarantee job data is fresh. Otherwise you could receive job data that is not up to date if a write transaction is occuring concurrently. 208 | * @return {promise} - Promise that resolves to a collection of all the jobs in the queue. 209 | */ 210 | async getJobs(sync = false) { 211 | 212 | if (sync) { 213 | 214 | let jobs = null; 215 | this.realm.write(() => { 216 | 217 | jobs = this.realm.objects('Job'); 218 | 219 | }); 220 | 221 | return jobs; 222 | 223 | } else { 224 | return await this.realm.objects('Job'); 225 | } 226 | 227 | } 228 | 229 | /** 230 | * 231 | * Get the next job(s) that should be processed by the queue. 232 | * 233 | * If the next job to be processed by the queue is associated with a 234 | * worker function that has concurrency X > 1, then X related (jobs with same name) 235 | * jobs will be returned. 236 | * 237 | * If queue is running with a lifespan, only jobs with timeouts at least 500ms < than REMAINING lifespan 238 | * AND a set timeout (ie timeout > 0) will be returned. See Queue.start() for more info. 239 | * 240 | * @param queueLifespanRemaining {number} - The remaining lifespan of the current queue process (defaults to indefinite). 241 | * @return {promise} - Promise resolves to an array of job(s) to be processed next by the queue. 242 | */ 243 | async getConcurrentJobs(queueLifespanRemaining = 0) { 244 | 245 | let concurrentJobs = []; 246 | 247 | this.realm.write(() => { 248 | 249 | // Get next job from queue. 250 | let nextJob = null; 251 | 252 | // Build query string 253 | // If queueLife 254 | const timeoutUpperBound = (queueLifespanRemaining - 500 > 0) ? queueLifespanRemaining - 499 : 0; // Only get jobs with timeout at least 500ms < queueLifespanRemaining. 255 | 256 | const initialQuery = (queueLifespanRemaining) 257 | ? 'active == FALSE AND failed == null AND timeout > 0 AND timeout < ' + timeoutUpperBound 258 | : 'active == FALSE AND failed == null'; 259 | 260 | let jobs = this.realm.objects('Job') 261 | .filtered(initialQuery) 262 | .sorted([['priority', true], ['created', false]]); 263 | 264 | if (jobs.length) { 265 | nextJob = jobs[0]; 266 | } 267 | 268 | // If next job exists, get concurrent related jobs appropriately. 269 | if (nextJob) { 270 | 271 | const concurrency = this.worker.getConcurrency(nextJob.name); 272 | 273 | const allRelatedJobsQuery = (queueLifespanRemaining) 274 | ? 'name == "'+ nextJob.name +'" AND active == FALSE AND failed == null AND timeout > 0 AND timeout < ' + timeoutUpperBound 275 | : 'name == "'+ nextJob.name +'" AND active == FALSE AND failed == null'; 276 | 277 | const allRelatedJobs = this.realm.objects('Job') 278 | .filtered(allRelatedJobsQuery) 279 | .sorted([['priority', true], ['created', false]]); 280 | 281 | let jobsToMarkActive = allRelatedJobs.slice(0, concurrency); 282 | 283 | // Grab concurrent job ids to reselect jobs as marking these jobs as active will remove 284 | // them from initial selection when write transaction exits. 285 | // See: https://stackoverflow.com/questions/47359368/does-realm-support-select-for-update-style-read-locking/47363356#comment81772710_47363356 286 | const concurrentJobIds = jobsToMarkActive.map( job => job.id); 287 | 288 | // Mark concurrent jobs as active 289 | jobsToMarkActive = jobsToMarkActive.map( job => { 290 | job.active = true; 291 | }); 292 | 293 | // Reselect now-active concurrent jobs by id. 294 | const reselectQuery = concurrentJobIds.map( jobId => 'id == "' + jobId + '"').join(' OR '); 295 | const reselectedJobs = this.realm.objects('Job') 296 | .filtered(reselectQuery) 297 | .sorted([['priority', true], ['created', false]]); 298 | 299 | concurrentJobs = reselectedJobs.slice(0, concurrency); 300 | 301 | } 302 | 303 | }); 304 | 305 | return concurrentJobs; 306 | 307 | } 308 | 309 | /** 310 | * 311 | * Process a job. 312 | * 313 | * Job lifecycle callbacks are called as appropriate throughout the job processing lifecycle. 314 | * 315 | * Job is deleted upon successful completion. 316 | * 317 | * If job fails execution via timeout or other exception, error will be 318 | * logged to job.data.errors array and job will be reset to inactive status. 319 | * Job will be re-attempted up to the specified "attempts" setting (defaults to 1), 320 | * after which it will be marked as failed and not re-attempted further. 321 | * 322 | * @param job {object} - Job realm model object 323 | */ 324 | async processJob(job) { 325 | 326 | // Data must be cloned off the realm job object for several lifecycle callbacks to work correctly. 327 | // This is because realm job is deleted before some callbacks are called if job processed successfully. 328 | // More info: https://github.com/billmalarky/react-native-queue/issues/2#issuecomment-361418965 329 | const jobName = job.name; 330 | const jobId = job.id; 331 | const jobPayload = JSON.parse(job.payload); 332 | 333 | // Fire onStart job lifecycle callback 334 | this.worker.executeJobLifecycleCallback('onStart', jobName, jobId, jobPayload); 335 | 336 | try { 337 | 338 | await this.worker.executeJob(job); 339 | 340 | // On successful job completion, remove job 341 | this.realm.write(() => { 342 | 343 | this.realm.delete(job); 344 | 345 | }); 346 | 347 | // Job has processed successfully, fire onSuccess and onComplete job lifecycle callbacks. 348 | this.worker.executeJobLifecycleCallback('onSuccess', jobName, jobId, jobPayload); 349 | this.worker.executeJobLifecycleCallback('onComplete', jobName, jobId, jobPayload); 350 | 351 | } catch (error) { 352 | 353 | // Handle job failure logic, including retries. 354 | let jobData = JSON.parse(job.data); 355 | 356 | this.realm.write(() => { 357 | 358 | // Increment failed attempts number 359 | if (!jobData.failedAttempts) { 360 | jobData.failedAttempts = 1; 361 | } else { 362 | jobData.failedAttempts++; 363 | } 364 | 365 | // Log error 366 | if (!jobData.errors) { 367 | jobData.errors = [ error.message ]; 368 | } else { 369 | jobData.errors.push(error.message); 370 | } 371 | 372 | job.data = JSON.stringify(jobData); 373 | 374 | // Reset active status 375 | job.active = false; 376 | 377 | // Mark job as failed if too many attempts 378 | if (jobData.failedAttempts >= jobData.attempts) { 379 | job.failed = new Date(); 380 | } 381 | 382 | }); 383 | 384 | // Execute job onFailure lifecycle callback. 385 | this.worker.executeJobLifecycleCallback('onFailure', jobName, jobId, jobPayload); 386 | 387 | // If job has failed all attempts execute job onFailed and onComplete lifecycle callbacks. 388 | if (jobData.failedAttempts >= jobData.attempts) { 389 | this.worker.executeJobLifecycleCallback('onFailed', jobName, jobId, jobPayload); 390 | this.worker.executeJobLifecycleCallback('onComplete', jobName, jobId, jobPayload); 391 | } 392 | 393 | } 394 | 395 | } 396 | 397 | /** 398 | * 399 | * Delete jobs in the queue. 400 | * 401 | * If jobName is supplied, only jobs associated with that name 402 | * will be deleted. Otherwise all jobs in queue will be deleted. 403 | * 404 | * @param jobName {string} - Name associated with job (and related job worker). 405 | */ 406 | flushQueue(jobName = null) { 407 | 408 | if (jobName) { 409 | 410 | this.realm.write(() => { 411 | 412 | let jobs = this.realm.objects('Job') 413 | .filtered('name == "' + jobName + '"'); 414 | 415 | if (jobs.length) { 416 | this.realm.delete(jobs); 417 | } 418 | 419 | }); 420 | 421 | } else { 422 | this.realm.write(() => { 423 | 424 | this.realm.deleteAll(); 425 | 426 | }); 427 | } 428 | 429 | } 430 | 431 | 432 | } 433 | 434 | /** 435 | * 436 | * Factory should be used to create a new queue instance. 437 | * 438 | * @return {Queue} - A queue instance. 439 | */ 440 | export default async function queueFactory() { 441 | 442 | const queue = new Queue(); 443 | await queue.init(); 444 | 445 | return queue; 446 | 447 | } 448 | -------------------------------------------------------------------------------- /Models/Worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Worker Model 4 | * 5 | */ 6 | 7 | export default class Worker { 8 | 9 | /** 10 | * 11 | * Singleton map of all worker functions assigned to queue. 12 | * 13 | */ 14 | static workers = {}; 15 | 16 | /** 17 | * 18 | * Assign a worker function to the queue. 19 | * 20 | * Worker will be called to execute jobs associated with jobName. 21 | * 22 | * Worker function will receive job id and job payload as parameters. 23 | * 24 | * Example: 25 | * 26 | * function exampleJobWorker(id, payload) { 27 | * console.log(id); // UUID of job. 28 | * console.log(payload); // Payload of data related to job. 29 | * } 30 | * 31 | * @param jobName {string} - Name associated with jobs assigned to this worker. 32 | * @param worker {function} - The worker function that will execute jobs. 33 | * @param options {object} - Worker options. See README.md for worker options info. 34 | */ 35 | addWorker(jobName, worker, options = {}) { 36 | 37 | // Validate input. 38 | if (!jobName || !worker) { 39 | throw new Error('Job name and associated worker function must be supplied.'); 40 | } 41 | 42 | // Attach options to worker 43 | worker.options = { 44 | concurrency: options.concurrency || 1, 45 | onStart: options.onStart || null, 46 | onSuccess: options.onSuccess || null, 47 | onFailure: options.onFailure || null, 48 | onFailed: options.onFailed || null, 49 | onComplete: options.onComplete || null 50 | }; 51 | 52 | Worker.workers[jobName] = worker; 53 | } 54 | 55 | /** 56 | * 57 | * Un-assign worker function from queue. 58 | * 59 | * @param jobName {string} - Name associated with jobs assigned to this worker. 60 | */ 61 | removeWorker(jobName) { 62 | delete Worker.workers[jobName]; 63 | } 64 | 65 | /** 66 | * 67 | * Get the concurrency setting for a worker. 68 | * 69 | * Worker concurrency defaults to 1. 70 | * 71 | * @param jobName {string} - Name associated with jobs assigned to this worker. 72 | * @throws Throws error if no worker is currently assigned to passed in job name. 73 | * @return {number} 74 | */ 75 | getConcurrency(jobName) { 76 | 77 | // If no worker assigned to job name, throw error. 78 | if (!Worker.workers[jobName]) { 79 | throw new Error('Job ' + jobName + ' does not have a worker assigned to it.'); 80 | } 81 | 82 | return Worker.workers[jobName].options.concurrency; 83 | 84 | } 85 | 86 | /** 87 | * 88 | * Execute the worker function assigned to the passed in job name. 89 | * 90 | * If job has a timeout setting, job will fail with a timeout exception upon reaching timeout. 91 | * 92 | * @throws Throws error if no worker is currently assigned to passed in job name. 93 | * @param job {object} - Job realm model object 94 | */ 95 | async executeJob(job) { 96 | 97 | // If no worker assigned to job name, throw error. 98 | if (!Worker.workers[job.name]) { 99 | throw new Error('Job ' + job.name + ' does not have a worker assigned to it.'); 100 | } 101 | 102 | // Data must be cloned off the realm job object for the timeout logic promise race. 103 | // More info: https://github.com/billmalarky/react-native-queue/issues/2#issuecomment-361418965 104 | const jobId = job.id; 105 | const jobName = job.name; 106 | const jobTimeout = job.timeout; 107 | const jobPayload = JSON.parse(job.payload); 108 | 109 | if (jobTimeout > 0) { 110 | 111 | let timeoutPromise = new Promise((resolve, reject) => { 112 | 113 | setTimeout(() => { 114 | reject(new Error('TIMEOUT: Job id: ' + jobId + ' timed out in ' + jobTimeout + 'ms.')); 115 | }, jobTimeout); 116 | 117 | }); 118 | 119 | await Promise.race([timeoutPromise, Worker.workers[jobName](jobId, jobPayload)]); 120 | 121 | } else { 122 | await Worker.workers[jobName](jobId, jobPayload); 123 | } 124 | 125 | } 126 | 127 | /** 128 | * 129 | * Execute an asynchronous job lifecycle callback associated with related worker. 130 | * 131 | * @param callbackName {string} - Job lifecycle callback name. 132 | * @param jobName {string} - Name associated with jobs assigned to related worker. 133 | * @param jobId {string} - Unique id associated with job. 134 | * @param jobPayload {object} - Data payload associated with job. 135 | */ 136 | async executeJobLifecycleCallback(callbackName, jobName, jobId, jobPayload) { 137 | 138 | // Validate callback name 139 | const validCallbacks = ['onStart', 'onSuccess', 'onFailure', 'onFailed', 'onComplete']; 140 | if (!validCallbacks.includes(callbackName)) { 141 | throw new Error('Invalid job lifecycle callback name.'); 142 | } 143 | 144 | // Fire job lifecycle callback if set. 145 | // Uses a try catch statement to gracefully degrade errors in production. 146 | if (Worker.workers[jobName].options[callbackName]) { 147 | 148 | try { 149 | await Worker.workers[jobName].options[callbackName](jobId, jobPayload); 150 | } catch (error) { 151 | console.error(error); // eslint-disable-line no-console 152 | } 153 | 154 | } 155 | 156 | } 157 | 158 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Native Queue 2 | 3 | # React Native Queue 4 | #### Simple. Powerful. Persistent. 5 | 6 | [![Build Status](https://travis-ci.org/billmalarky/react-native-queue.svg?branch=master)](https://travis-ci.org/billmalarky/react-native-queue) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/billmalarky/react-native-queue/blob/master/LICENSE) 8 | [![ESLint](https://img.shields.io/badge/eslint-ok-green.svg)](https://github.com/billmalarky/react-native-queue/blob/master/.eslintrc.js) 9 | [![JSDoc](https://img.shields.io/badge/jsdoc-100%25%20code%20documentation-green.svg)](http://usejsdoc.org/) 10 | [![Coverage Status](https://coveralls.io/repos/github/billmalarky/react-native-queue/badge.svg?branch=master)](https://coveralls.io/github/billmalarky/react-native-queue?branch=master) 11 | 12 | A React Native at-least-once priority job queue / task queue backed by persistent Realm storage. Jobs will persist until completed, even if user closes and re-opens app. React Native Queue is easily integrated into OS background processes (services) so you can ensure the queue will continue to process until all jobs are completed even if app isn't in focus. It also plays well with Workers so your jobs can be thrown on the queue, then processed in dedicated worker threads for greatly improved processing performance. 13 | 14 | ## Table of Contents 15 | 16 | * [Features](#features) 17 | * [Example Use Cases](#example-use-cases) 18 | * [Installation](#installation) 19 | * [Basic Usage](#basic-usage) 20 | * [Options and Job Lifecycle Callbacks](#options-and-job-lifecycle-callbacks) 21 | * [Testing with Jest](#testing-with-jest) 22 | * [Caveats](#caveats) 23 | * [Advanced Usage Examples](#advanced-usage-examples) 24 | * [Advanced Job Full Example](#advanced-job-full-example) 25 | * [OS Background Task Full Example](#os-background-task-full-example) 26 | * [Tutorials](#tutorials) 27 | 28 | ## Features 29 | 30 | * **Simple API:** Set up job workers and begin creating your jobs in minutes with just two basic API calls 31 | * queue.addWorker(name, workerFunction, options = {}) 32 | * queue.createJob(name, payload = {}, options = {}, startQueue = true) 33 | * **Powerful options:** Easily modify default functionality. Set job timeouts, number of retry attempts, priority, job lifecycle callbacks, and worker concurrency with an options object. Start queue processing with a lifespan to easily meet OS background task time limits. 34 | * **Persistent Jobs:** Jobs are persisted with Realm. Because jobs persist, you can easily continue to process jobs across app restarts or in OS background tasks until completed or failed (or app is uninstalled). 35 | * **Powerful Integrations:** React Native Queue was designed to play well with others. The queue quickly integrates with a variety of OS background task and Worker packages so processing your jobs in a background service or dedicated thread have never been easier. 36 | 37 | ## Example Use Cases 38 | 39 | **React Native Queue is designed to be a swiss army knife for task management in React Native**. It abstracts away the many annoyances related to processing complex tasks, like durability, retry-on-failure, timeouts, chaining processes, and more. **Just throw your jobs onto the queue and relax - they're covered**. 40 | 41 | Need advanced task functionality like dedicated worker threads or OS services? Easy: 42 | 43 | * **React Native Queue + React Native Background Task:** [Simple and Powerful OS services](#os-background-task-full-example) that fire when your app is closed. 44 | * **React Native Queue + React Native Workers:** Spinning up [dedicated worker threads](https://gist.github.com/billmalarky/1d1f72a267a1608606410602aa63cabf) for CPU intensive tasks has never been easier. 45 | 46 | **Example Queue Tasks:** 47 | 48 | * Downloading content for offline access. 49 | * Media processing. 50 | * Cache Warming. 51 | * _Durable_ API calls to external services, such as publishing content to a variety of 3rd party distribution channel APIs. 52 | * Complex and time-consuming jobs that you want consistently processed regardless if app is open, closed, or repeatedly opened and closed. 53 | * Complex tasks with multiple linked dependant steps (job chaining). 54 | 55 | ## Installation 56 | 57 | ```bash 58 | $ npm install --save react-native-queue 59 | ``` 60 | 61 | Or 62 | 63 | ```bash 64 | $ yarn add react-native-queue 65 | ``` 66 | 67 | Then, because this package has a depedency on [Realm](https://github.com/realm/realm-js) you will need to link this native package by running: 68 | 69 | ```bash 70 | $ react-native link realm 71 | ``` 72 | 73 | Linking realm **should only be done once**, reinstalling node_modules with npm or yarn does not require running the above command again. 74 | 75 | To troubleshoot linking, refer to [the realm installation instructions](https://realm.io/docs/javascript/latest/#getting-started). 76 | 77 | ## Basic Usage 78 | 79 | React Native Queue is a standard job/task queue built specifically for react native applications. If you have a long-running task, or a large number of tasks, consider turning that task into a job(s) and throwing it/them onto the queue to be processed in the background instead of blocking your UI until task(s) complete. 80 | 81 | Creating and processing jobs consists of: 82 | 83 | 1. Importing and initializing React Native Queue 84 | 2. Registering worker functions (the functions that execute your jobs). 85 | 3. Creating jobs. 86 | 4. Starting the queue (note this happens automatically on job creation, but sometimes the queue must be explicitly started such as in a OS background task or on app restart). Queue can be started with a lifespan in order to limit queue processing time. 87 | 88 | ```js 89 | 90 | import queueFactory from 'react-native-queue'; 91 | 92 | // Of course this line needs to be in the context of an async function, 93 | // otherwise use queueFactory.then((queue) => { console.log('add workers and jobs here'); }); 94 | const queue = await queueFactory(); 95 | 96 | // Register the worker function for "example-job" jobs. 97 | queue.addWorker('example-job', async (id, payload) => { 98 | console.log('EXECUTING "example-job" with id: ' + id); 99 | console.log(payload, 'payload'); 100 | 101 | await new Promise((resolve) => { 102 | setTimeout(() => { 103 | console.log('"example-job" has completed!'); 104 | resolve(); 105 | }, 5000); 106 | }); 107 | 108 | }); 109 | 110 | // Create a couple "example-job" jobs. 111 | 112 | // Example job passes a payload of data to 'example-job' worker. 113 | // Default settings are used (note the empty options object). 114 | // Because false is passed, the queue won't automatically start when this job is created, so usually queue.start() 115 | // would have to be manually called. However in the final createJob() below we don't pass false so it will start the queue. 116 | // NOTE: We pass false for example purposes. In most scenarios starting queue on createJob() is perfectly fine. 117 | queue.createJob('example-job', { 118 | emailAddress: 'foo@bar.com', 119 | randomData: { 120 | random: 'object', 121 | of: 'arbitrary data' 122 | } 123 | }, {}, false); 124 | 125 | // Create another job with an example timeout option set. 126 | // false is passed so queue still hasn't started up. 127 | queue.createJob('example-job', { 128 | emailAddress: 'example@gmail.com', 129 | randomData: { 130 | random: 'object', 131 | of: 'arbitrary data' 132 | } 133 | }, { 134 | timeout: 1000 // This job will timeout in 1000 ms and be marked failed (since worker takes 5000 ms to complete). 135 | }, false); 136 | 137 | // This will automatically start the queue after adding the new job so we don't have to manually call queue.start(). 138 | queue.createJob('example-job', { 139 | emailAddress: 'another@gmail.com', 140 | randomData: { 141 | random: 'object', 142 | of: 'arbitrary data' 143 | } 144 | }); 145 | 146 | console.log('The above jobs are processing in the background of app now.'); 147 | 148 | ``` 149 | 150 | ## Options and Job Lifecycle Callbacks 151 | 152 | #### Worker Options (includes async job lifecycle callbacks) 153 | 154 | queue.addWorker() accepts an options object in order to tweak standard functionality and allow you to hook into asynchronous job lifecycle callbacks. 155 | 156 | **IMPORTANT: Job Lifecycle callbacks are called asynchronously.** They do not block job processing or each other. Don't put logic in onStart that you expect to be completed before the actual job process begins executing. Don't put logic in onFailure you expect to be completed before onFailed is called. You can, of course, assume that the job process has completed (or failed) before onSuccess, onFailure, onFailed, or onComplete are asynchonrously called. 157 | 158 | ```js 159 | 160 | queue.addWorker('job-name-here', async (id, payload) => { console.log(id); }, { 161 | 162 | // Set max number of jobs for this worker to process concurrently. 163 | // Defaults to 1. 164 | concurrency: 5, 165 | 166 | // JOB LIFECYCLE CALLBACKS 167 | 168 | // onStart job callback handler is fired when a job begins processing. 169 | // 170 | // IMPORTANT: Job lifecycle callbacks are executed asynchronously and do not block job processing 171 | // (even if the callback returns a promise it will not be "awaited" on). 172 | // As such, do not place any logic in onStart that your actual job worker function will depend on, 173 | // this type of logic should of course go inside the job worker function itself. 174 | onStart: async (id, payload) => { 175 | 176 | console.log('Job "job-name-here" with id ' + id + ' has started processing.'); 177 | 178 | }, 179 | 180 | // onSuccess job callback handler is fired after a job successfully completes processing. 181 | onSuccess: async (id, payload) => { 182 | 183 | console.log('Job "job-name-here" with id ' + id + ' was successful.'); 184 | 185 | }, 186 | 187 | // onFailure job callback handler is fired after each time a job fails (onFailed also fires if job has reached max number of attempts). 188 | onFailure: async (id, payload) => { 189 | 190 | console.log('Job "job-name-here" with id ' + id + ' had an attempt end in failure.'); 191 | 192 | }, 193 | 194 | // onFailed job callback handler is fired if job fails enough times to reach max number of attempts. 195 | onFailed: async (id, payload) => { 196 | 197 | console.log('Job "job-name-here" with id ' + id + ' has failed.'); 198 | 199 | }, 200 | 201 | // onComplete job callback handler fires after job has completed processing successfully or failed entirely. 202 | onComplete: async (id, payload) => { 203 | 204 | console.log('Job "job-name-here" with id ' + id + ' has completed processing.'); 205 | 206 | } 207 | 208 | }); 209 | 210 | ``` 211 | 212 | #### Job Options 213 | 214 | queue.createJob() accepts an options object in order to tweak standard functionality. 215 | 216 | ```js 217 | 218 | queue.createJob('job-name-here', {foo: 'bar'}, { 219 | 220 | // Higher priority jobs (10) get processed before lower priority jobs (-10). 221 | // Any int will work, priority 1000 will be processed before priority 10, though this is probably overkill. 222 | // Defaults to 0. 223 | priority: 10, // High priority 224 | 225 | // Timeout in ms before job is considered failed. 226 | // Use this setting to kill off hanging jobs that are clogging up 227 | // your queue, or ensure your jobs finish in a timely manner if you want 228 | // to execute jobs in OS background tasks. 229 | // 230 | // IMPORTANT: Jobs are required to have a timeout > 0 set in order to be processed 231 | // by a queue that has been started with a lifespan. As such, if you want to process 232 | // jobs in an OS background task, you MUST give the jobs a timeout setting. 233 | // 234 | // Setting this option to 0 means never timeout. 235 | // 236 | // Defaults to 25000. 237 | timeout: 30000, // Timeout in 30 seconds 238 | 239 | // Number of times to attempt a failing job before marking job as failed and moving on. 240 | // Defaults to 1. 241 | attempts: 4, // If this job fails to process 4 times in a row, it will be marked as failed. 242 | 243 | }); 244 | 245 | 246 | ``` 247 | 248 | ## Testing with Jest 249 | 250 | Because realm will write database files to the root test directory when running jest tests, you will need to add the following to your gitignore file if you use tests. 251 | 252 | ```text 253 | /reactNativeQueue.realm* 254 | ``` 255 | 256 | ## Caveats 257 | 258 | **Jobs must be idempotent.** As with most queues, there are certain scenarios that could lead to React Native Queue processing a job more than once. For example, a job could timeout locally but remote server actions kicked off by the job could continue to execute. If the job is retried then effectively the remote code will be run twice. Furthermore, a job could fail due to some sort of exception halfway through then the next time it runs the first half of the job has already been executed once. Always design your React Native Queue jobs to be idempotent. If this is not possible, set job "attempts" option to be 1 (the default setting), and then you will have to write custom logic to handle the event of a job failing (perhaps via a job chain). 259 | 260 | ## Advanced Usage Examples 261 | 262 | #### Advanced Job Full Example 263 | 264 | ```js 265 | 266 | import React, { Component } from 'react'; 267 | import { 268 | Platform, 269 | StyleSheet, 270 | Text, 271 | View, 272 | Button 273 | } from 'react-native'; 274 | 275 | import queueFactory from 'react-native-queue'; 276 | 277 | export default class App extends Component<{}> { 278 | 279 | constructor(props) { 280 | super(props); 281 | 282 | this.state = { 283 | queue: null 284 | }; 285 | 286 | this.init(); 287 | 288 | } 289 | 290 | async init() { 291 | 292 | const queue = await queueFactory(); 293 | 294 | // 295 | // Standard Job Example 296 | // Nothing fancy about this job. 297 | // 298 | queue.addWorker('standard-example', async (id, payload) => { 299 | console.log('standard-example job '+id+' executed.'); 300 | }); 301 | 302 | // 303 | // Recursive Job Example 304 | // This job creates itself over and over. 305 | // 306 | let recursionCounter = 1; 307 | queue.addWorker('recursive-example', async (id, payload) => { 308 | console.log('recursive-example job '+ id +' started'); 309 | console.log(recursionCounter, 'recursionCounter'); 310 | 311 | recursionCounter++; 312 | 313 | await new Promise((resolve) => { 314 | setTimeout(() => { 315 | console.log('recursive-example '+ id +' has completed!'); 316 | 317 | // Keep creating these jobs until counter reaches 3. 318 | if (recursionCounter <= 3) { 319 | queue.createJob('recursive-example'); 320 | } 321 | 322 | resolve(); 323 | }, 1000); 324 | }); 325 | 326 | }); 327 | 328 | // 329 | // Job Chaining Example 330 | // When job completes, it creates a new job to handle the next step 331 | // of your process. Breaking large jobs up into smaller jobs and then 332 | // chaining them together will allow you to handle large tasks in 333 | // OS background tasks, that are limited to 30 seconds of 334 | // execution every 15 minutes on iOS and Android. 335 | // 336 | queue.addWorker('start-job-chain', async (id, payload) => { 337 | console.log('start-job-chain job '+ id +' started'); 338 | console.log('step: ' + payload.step); 339 | 340 | await new Promise((resolve) => { 341 | setTimeout(() => { 342 | console.log('start-job-chain '+ id +' has completed!'); 343 | 344 | // Create job for next step in chain 345 | queue.createJob('job-chain-2nd-step', { 346 | callerJobName: 'start-job-chain', 347 | step: payload.step + 1 348 | }); 349 | 350 | resolve(); 351 | }, 1000); 352 | }); 353 | 354 | }); 355 | 356 | queue.addWorker('job-chain-2nd-step', async (id, payload) => { 357 | console.log('job-chain-2nd-step job '+ id +' started'); 358 | console.log('step: ' + payload.step); 359 | 360 | await new Promise((resolve) => { 361 | setTimeout(() => { 362 | console.log('job-chain-2nd-step '+ id +' has completed!'); 363 | 364 | // Create job for last step in chain 365 | queue.createJob('job-chain-final-step', { 366 | callerJobName: 'job-chain-2nd-step', 367 | step: payload.step + 1 368 | }); 369 | 370 | resolve(); 371 | }, 1000); 372 | }); 373 | 374 | }); 375 | 376 | queue.addWorker('job-chain-final-step', async (id, payload) => { 377 | console.log('job-chain-final-step job '+ id +' started'); 378 | console.log('step: ' + payload.step); 379 | 380 | await new Promise((resolve) => { 381 | setTimeout(() => { 382 | console.log('job-chain-final-step '+ id +' has completed!'); 383 | console.log('Job chain is now completed!'); 384 | 385 | resolve(); 386 | }, 1000); 387 | }); 388 | 389 | }); 390 | 391 | // Start queue to process any jobs that hadn't finished when app was last closed. 392 | queue.start(); 393 | 394 | // Attach initialized queue to state. 395 | this.setState({ 396 | queue 397 | }); 398 | 399 | } 400 | 401 | makeJob(jobName, payload = {}) { 402 | this.state.queue.createJob(jobName, payload); 403 | } 404 | 405 | render() { 406 | 407 | return ( 408 | 409 | 410 | Welcome to React Native! 411 | 412 | {this.state.queue &&