diff --git a/README.md b/README.md index ba7b077..7ba7e0e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ concurrency: 4 reconnectTimeout: 10 refreshRate: 1000 tmpDir: /dev/shm/printnatura +retryAttempts: 3 +retryTimeout: 1000 db: host: localhost port: 3306 @@ -44,7 +46,6 @@ salix: Exec ``` > docker run --name printnatura -it --rm -v $PWD/config.local.yml:/printnatura/config.local.yml:ro -v $PWD/cupsd.conf:/etc/cups/cupsd.conf:ro -p 80:631 printnatura - ``` Bash diff --git a/config.yml b/config.yml index 31c230b..f072e64 100644 --- a/config.yml +++ b/config.yml @@ -7,6 +7,8 @@ concurrency: 4 reconnectTimeout: 10 refreshRate: 1000 tmpDir: /dev/shm/printnatura +retryAttempts: 3 +retryTimeout: 1500 db: host: localhost port: 3306 diff --git a/print-server.js b/print-server.js index 27a6712..8b7f6fd 100644 --- a/print-server.js +++ b/print-server.js @@ -15,322 +15,335 @@ const errorQuery = fs.readFileSync(`sql/updateError.sql`).toString(); const printedQuery = fs.readFileSync(`sql/updatePrinted.sql`).toString(); class PrintServer { - async start() { - let conf = yml(path.join(__dirname, 'config.yml')); - const localConfFile = path.join(__dirname, 'config.local.yml'); - if (fs.existsSync(localConfFile)) - conf = Object.assign({}, conf, yml(localConfFile)); - this.conf = conf; - this.jobs = []; + async start() { + let conf = yml(path.join(__dirname, 'config.yml')); + const localConfFile = path.join(__dirname, 'config.local.yml'); + if (fs.existsSync(localConfFile)) + conf = Object.assign({}, conf, yml(localConfFile)); + this.conf = conf; + this.jobs = []; - console.clear(); - const decoration = '△▽'.repeat(10); - const printnatura = colors.bgBlack.bold(' Print'.white + 'Natura '.green); - console.log(`${decoration} ${printnatura} ${decoration}`); - if (this.conf.debug) - this.serverLog('log', 'Debug mode enabled'.yellow); - if (this.conf.dryPrint) - this.serverLog('log', 'Running in dry print mode, documents won\'t be printed'.yellow); - if (this.conf.keepFile) - this.serverLog('log', 'Keep file enabled, documents won\'t be deleted from disk'.yellow); + console.clear(); + const decoration = '△▽'.repeat(10); + const printnatura = colors.bgBlack.bold(' Print'.white + 'Natura '.green); + console.log(`${decoration} ${printnatura} ${decoration}`); + if (this.conf.debug) + this.serverLog('log', 'Debug mode enabled'.yellow); + if (this.conf.dryPrint) + this.serverLog('log', 'Running in dry print mode, documents won\'t be printed'.yellow); + if (this.conf.keepFile) + this.serverLog('log', 'Keep file enabled, documents won\'t be deleted from disk'.yellow); - await this.init(); - this.rejectionHandler = (err, p) => this.onRejection(err, p); - process.on('unhandledRejection', this.rejectionHandler); + await this.init(); + this.rejectionHandler = (err, p) => this.onRejection(err, p); + process.on('unhandledRejection', this.rejectionHandler); - this.serverLog('log', 'Ready to print'.green); - await this.poll(); - } - async stop() { - this.serverLog('log', 'Bye ( ◕ ‿ ◕ )っ'.green); - process.off('unhandledRejection', this.rejectionHandler); - await this.end(); - } - async init() { - const conf = this.conf; - const api = this.api = axios.create({ - baseURL: `${conf.salix.url}/api/` - }); - api.interceptors.request.use(config => { - if (this.token) - config.headers['Authorization'] = this.token - return config; - }); - await this.getToken(); + this.serverLog('log', 'Ready to print'.green); + await this.poll(); + } + async stop() { + this.serverLog('log', 'Bye ( ◕ ‿ ◕ )っ'.green); + process.off('unhandledRejection', this.rejectionHandler); + await this.end(); + } + async init() { + const conf = this.conf; + const api = this.api = axios.create({ + baseURL: `${conf.salix.url}/api/` + }); + api.interceptors.request.use(config => { + if (this.token) + config.headers['Authorization'] = this.token + return config; + }); + await this.getToken(); - this.pool = await mysql.createPool(conf.db); - } - async end() { - if (this.token) { - await this.api.post(`Accounts/logout`); - this.token = null; - } - if (this.pollTimeout) { - clearTimeout(this.pollTimeout); - this.pollTimeout = null; - } - await this.pool.end(); - } - async getToken() { - const salix = this.conf.salix; - let response = await this.api.post(`Accounts/login`, { - user: salix.user, - password: salix.password - }); - this.token = response.data.token; - } - serverLog(realm, message) { - this.log(`Server`, realm, message); - } - log(classMsg, realm, message) { - classMsg = `${classMsg}:`; + this.pool = await mysql.createPool(conf.db); + } + async end() { + if (this.token) { + await this.api.post(`Accounts/logout`); + this.token = null; + } + if (this.pollTimeout) { + clearTimeout(this.pollTimeout); + this.pollTimeout = null; + } + await this.pool.end(); + } + async getToken() { + const salix = this.conf.salix; + let response = await this.api.post(`Accounts/login`, { + user: salix.user, + password: salix.password + }); + this.token = response.data.token; + } + serverLog(realm, message) { + this.log(`Server`, realm, message); + } + log(classMsg, realm, message) { + classMsg = `${classMsg}:`; - switch(realm) { - case 'debug': - if (this.conf.debug) console.debug(classMsg, message.magenta); - break; - case 'error': - console.error(classMsg, message.red); - break; - default: - if (this.conf.log) console.log(classMsg, message); - } - } - onRejection(err, p) { - console.debug('unhandledRejection'); - this.errorHandler(err); - } - errorHandler(err) { - if (err.code === 'ETIMEDOUT') { - if (!this.dbDown) { - this.dbDown = true; - this.serverLog('error', `DB connection lost: ${err.message}`); - } - } else - console.error(err); - } - async poll() { - this.pollTimeout = null; - let delay = this.conf.refreshRate; - await this.getJobs(); - if (this.dbDown) delay = this.conf.reconnectTimeout * 1000; - this.pollTimeout = setTimeout(() => this.poll(), delay); - } - async getJobs() { - if (this.polling) return; - this.polling = true; - const conf = this.conf; + switch(realm) { + case 'debug': + if (this.conf.debug) console.debug(classMsg, message.magenta); + break; + case 'error': + console.error(classMsg, message.red); + break; + default: + if (this.conf.log) console.log(classMsg, message); + } + } + onRejection(err, p) { + console.debug('unhandledRejection'); + this.errorHandler(err); + } + errorHandler(err) { + if (err.code === 'ETIMEDOUT') { + if (!this.dbDown) { + this.dbDown = true; + this.serverLog('error', `DB connection lost: ${err.message}`); + } + } else + console.error(err); + } + async poll() { + this.pollTimeout = null; + let delay = this.conf.refreshRate; + await this.getJobs(); + if (this.dbDown) delay = this.conf.reconnectTimeout * 1000; + this.pollTimeout = setTimeout(() => this.poll(), delay); + } + async getJobs() { + if (this.polling) return; + this.polling = true; + const conf = this.conf; - if (this.dbDown) { - try { - const conn = await this.pool.getConnection(); - try { - await conn.ping(); - this.dbDown = false; - this.serverLog('log', 'DB connection recovered'.green); - } catch (err) { - conn.release(); - } - } catch(e) {} - } + if (this.dbDown) { + try { + const conn = await this.pool.getConnection(); + try { + await conn.ping(); + this.dbDown = false; + this.serverLog('log', 'DB connection recovered'.green); + } catch (err) { + conn.release(); + } + } catch(e) {} + } - if (!this.dbDown) { - try { - let nJobs = 0; - while (this.jobs.length < conf.concurrency) { - const jobId = await this.getJob(); - if (jobId) { - nJobs++; - this.jobs.push(jobId); - const jobP = this.printJob(jobId); - // XXX: Workaround for Promise unhandledRejection - // https://stackoverflow.com/questions/67789309/why-do-i-get-an-unhandled-promise-rejection-with-await-promise-all - jobP.catch(err => this.errorHandler(err)); - } else - break; - } - if (nJobs > 0) - this.serverLog('debug', `${nJobs} jobs buffered`); - } catch (err) { - this.errorHandler(err); - } - } + if (!this.dbDown) { + try { + let nJobs = 0; + while (this.jobs.length < conf.concurrency) { + const jobId = await this.getJob(); + if (jobId) { + nJobs++; + this.jobs.push(jobId); + const jobP = this.printJob(jobId); + // XXX: Workaround for Promise unhandledRejection + // https://stackoverflow.com/questions/67789309/why-do-i-get-an-unhandled-promise-rejection-with-await-promise-all + jobP.catch(err => this.errorHandler(err)); + } else + break; + } + if (nJobs > 0) + this.serverLog('debug', `${nJobs} jobs buffered`); + } catch (err) { + this.errorHandler(err); + } + } - this.polling = false; - } - async getJob() { - let jobId; + this.polling = false; + } + async getJob() { + let jobId; - const conn = await this.pool.getConnection(); - try { - await conn.beginTransaction(); - try { - const [[printJob]] = await conn.query(selectQuery); - if (printJob) { - jobId = printJob.id; - await conn.query(printingQuery, [ - 'printing', - now(), - null, - null, - this.conf.serverId || os.hostname, - jobId - ]); - this.jobLog(jobId, 'debug', 'get: printing'); - await conn.commit(); - } else - await conn.rollback(); - } catch (err) { - try { - await conn.rollback(); - } catch (e) {} - if (jobId) - this.jobLog(jobId, 'error', err.message); - throw err; - } - } finally { - conn.release(); - } + const conn = await this.pool.getConnection(); + try { + await conn.beginTransaction(); + try { + const [[printJob]] = await conn.query(selectQuery); + if (printJob) { + jobId = printJob.id; + await conn.query(printingQuery, [ + 'printing', + now(), + null, + null, + this.conf.serverId || os.hostname, + jobId + ]); + this.jobLog(jobId, 'debug', 'get: printing'); + await conn.commit(); + } else + await conn.rollback(); + } catch (err) { + try { + await conn.rollback(); + } catch (e) {} + if (jobId) + this.jobLog(jobId, 'error', err.message); + throw err; + } + } finally { + conn.release(); + } - return jobId; - } - async printJob(jobId) { - const conf = this.conf; - let jobData; - const args = {}; - let tmpFilePath; - let tmpFileCreated = false; + return jobId; + } + async printJob(jobId) { + const conf = this.conf; + let jobData; + const args = {}; + let tmpFilePath; + let tmpFileCreated = false; - let conn; - try { - conn = await this.pool.getConnection(); + let conn; + try { + conn = await this.pool.getConnection(); - // Job data - await conn.beginTransaction(); - try { - const [[data]] = await conn.query(jobDataQuery, jobId); - jobData = data; + // Job data + await conn.beginTransaction(); + try { + const [[data]] = await conn.query(jobDataQuery, jobId); + jobData = data; - const [res] = await conn.query(jobArgsQuery, jobId); - for (const row of res) - args[row.name] = row.value; - } finally { - await conn.rollback(); - } + const [res] = await conn.query(jobArgsQuery, jobId); + for (const row of res) + args[row.name] = row.value; + } finally { + await conn.rollback(); + } - // Path params - const usedParams = new Set(); - const methodPath = jobData.method.replace(/{\w+}/g, function(match) { - const key = match.substr(1, match.length - 2); - const value = args[key]; - usedParams.add(key); - return value !== undefined ? value : match; - }); + // Path params + const usedParams = new Set(); + const methodPath = jobData.method.replace(/{\w+}/g, function(match) { + const key = match.substr(1, match.length - 2); + const value = args[key]; + usedParams.add(key); + return value !== undefined ? value : match; + }); - // URL params - const params = {userFk: jobData.userFk}; - for (const key in args) { - if (!usedParams.has(key)) - params[key] = args[key]; - } - const urlParams = new URLSearchParams(params); + // URL params + const params = {userFk: jobData.userFk}; + for (const key in args) { + if (!usedParams.has(key)) + params[key] = args[key]; + } + const urlParams = new URLSearchParams(params); - const url = `${methodPath}?${urlParams.toString()}`; - this.jobLog(jobId, 'debug', `api: ${url}`); + const url = `${methodPath}?${urlParams.toString()}`; + this.jobLog(jobId, 'debug', `api: ${url}`); - // Request - let pdfData; - for (let attempts = 0; !pdfData && attempts < 2; attempts++) { - try { - const res = await this.api({ - method: 'get', - url, - responseType: 'arraybuffer', - headers: {'Accept': 'application/pdf'} - }); - pdfData = res.data; - } - catch (err) { - if (err.name === 'AxiosError' && err.code === 'ERR_BAD_REQUEST') { - const res = err.response; - if (res.status === 401) { // Unauthorized - await this.getToken(); - } else { - const resMessage = JSON.parse(res.data).error.message; - const resErr = new Error(`${err.message}: ${resMessage}`); - resErr.stack = err.stack; - throw resErr; - } - } else - throw err; - } - } + // Request + let pdfData; + for (let attempts = 0; !pdfData && attempts < this.conf.retryAttempts; attempts++) { + try { + const res = await this.api({ + method: 'get', + url, + responseType: 'arraybuffer', + headers: {'Accept': 'application/pdf'} + }); + pdfData = res.data; + } + catch (err) { + if (err.name === 'AxiosError' && attempts < this.conf.retryAttempts - 1) { + const res = err.response; + switch(res.status) { + case 401: // Unauthorized + await this.getToken(); + break; + case 502 || 504: // Bad Gateway & Gateway Timeout + await new Promise( + resolve => setTimeout(resolve, this.conf.retryTimeout)); + break; + default: + try { + if (err.code === 'ERR_BAD_REQUEST') { + const resMessage = JSON.parse(res.data).error.message; + const resErr = new Error(`${err.message}: ${resMessage}`); + resErr.stack = err.stack; + throw resErr; + } else + throw err; + } catch (err) { + throw err; + } + } + } else + throw err; + } + } - // Save PDF to disk - const printer = jobData.printer; - const tmpPath = conf.tmpDir; - if (!fs.existsSync(tmpPath)) - fs.mkdirSync(tmpPath) - tmpFilePath = path.join(tmpPath, `job-${jobId}.pdf`); - await fs.writeFile(tmpFilePath, pdfData, 'binary'); - tmpFileCreated = true; + // Save PDF to disk + const printer = jobData.printer; + const tmpPath = conf.tmpDir; + if (!fs.existsSync(tmpPath)) + fs.mkdirSync(tmpPath) + tmpFilePath = path.join(tmpPath, `job-${jobId}.pdf`); + await fs.writeFile(tmpFilePath, pdfData, 'binary'); + tmpFileCreated = true; - // Print PDF - const printCommand = `lp -d "${printer}" "${tmpFilePath}"`; - this.jobLog(jobId, 'debug', `print: ${printCommand}`); - try { - if (!conf.dryPrint) await pExec(printCommand); - } catch(err) { - throw new Error(`Print error: ${err.message}`); - } + // Print PDF + const printCommand = `lp -d "${printer}" "${tmpFilePath}"`; + this.jobLog(jobId, 'debug', `print: ${printCommand}`); + try { + if (!conf.dryPrint) await pExec(printCommand); + } catch(err) { + throw new Error(`Print error: ${err.message}`); + } - await conn.query(printedQuery, ['printed', now(), jobId]); - this.jobLog(jobId, 'log', `${jobData.report}: '${printCommand}': GET ${url}`); - } catch (err) { - try { - await conn.query(errorQuery, ['error', now(), err.message, jobId]); - } catch (e) { - this.jobLog(jobId, 'error', e.message); - } + await conn.query(printedQuery, ['printed', now(), jobId]); + this.jobLog(jobId, 'log', `${jobData.report}: '${printCommand}': GET ${url}`); + } catch (err) { + try { + await conn.query(errorQuery, ['error', now(), err.message, jobId]); + } catch (e) { + this.jobLog(jobId, 'error', e.message); + } - this.jobLog(jobId, 'error', err.message); - const jobErr = new Error(`(${jobId}) ${err.message}`); - jobErr.stack = err.stack; - throw jobErr; - } finally { - if (conn) conn.release(); + this.jobLog(jobId, 'error', err.message); + const jobErr = new Error(`(${jobId}) ${err.message}`); + jobErr.stack = err.stack; + throw jobErr; + } finally { + if (conn) conn.release(); - if (!conf.keepFile) { - try { - const shouldDelete = tmpFileCreated - || (tmpFilePath && await fs.pathExists(tmpFilePath)); - if (shouldDelete) await fs.unlink(tmpFilePath); - } catch (err) { - this.jobLog(jobId, 'error', err.message); - } - } + if (!conf.keepFile) { + try { + const shouldDelete = tmpFileCreated + || (tmpFilePath && await fs.pathExists(tmpFilePath)); + if (shouldDelete) await fs.unlink(tmpFilePath); + } catch (err) { + this.jobLog(jobId, 'error', err.message); + } + } - const index = this.jobs.indexOf(jobId); - if (index !== -1) this.jobs.splice(index, 1); - setTimeout(() => this.getJobs()); - } - } - jobLog(jobId, realm, message) { - this.log(`Job[${colors.yellow(jobId)}]`, realm, message); - } + const index = this.jobs.indexOf(jobId); + if (index !== -1) this.jobs.splice(index, 1); + setTimeout(() => this.getJobs()); + } + } + jobLog(jobId, realm, message) { + this.log(`Job[${colors.yellow(jobId)}]`, realm, message); + } } module.exports = PrintServer; function pExec(command) { - return new Promise(function(resolve, reject) { - exec(command, function(err, stdout, stderr) { - if (err) return reject(err); - resolve({stdout, stderr}) - }); - }); + return new Promise(function(resolve, reject) { + exec(command, function(err, stdout, stderr) { + if (err) return reject(err); + resolve({stdout, stderr}) + }); + }); } function now() { - return new Date().toISOString().slice(0, 19).replace('T', ' '); + return new Date().toISOString().slice(0, 19).replace('T', ' '); } \ No newline at end of file