Merge pull request #17731 from overleaf/em-promisify-error-recorder

Promisify ErrorRecorder

GitOrigin-RevId: 3736567272a09b4e3b9075118460392c1f66f0d7
This commit is contained in:
Eric Mc Sween 2024-04-11 08:33:03 -04:00 committed by Copybot
parent f03e3fd51e
commit 3b555ac9e6
2 changed files with 214 additions and 293 deletions

View file

@ -1,32 +1,9 @@
// TODO: This file was created by bulk-decaffeinate. import { callbackify } from 'util'
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { promisify } from 'util'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics' import metrics from '@overleaf/metrics'
import OError from '@overleaf/o-error'
import { db } from './mongodb.js' import { db } from './mongodb.js'
export function record(projectId, queueSize, error, callback) { async function record(projectId, queueSize, error) {
if (callback == null) {
callback = function () {}
}
const _callback = function (mongoError) {
if (mongoError != null) {
logger.error(
{ projectId, mongoError },
'failed to change project statues in mongo'
)
}
return callback(error || null, queueSize)
}
if (error != null) { if (error != null) {
const errorRecord = { const errorRecord = {
queueSize, queueSize,
@ -38,69 +15,62 @@ export function record(projectId, queueSize, error, callback) {
{ projectId, errorRecord }, { projectId, errorRecord },
'recording failed attempt to process updates' 'recording failed attempt to process updates'
) )
return db.projectHistoryFailures.updateOne( try {
{ await db.projectHistoryFailures.updateOne(
project_id: projectId, { project_id: projectId },
}, {
{ $set: errorRecord,
$set: errorRecord, $inc: { attempts: 1 },
$inc: { $push: {
attempts: 1, history: {
$each: [errorRecord],
$position: 0,
$slice: 10,
},
}, // only keep recent failures
}, },
$push: { { upsert: true }
history: { )
$each: [errorRecord], } catch (mongoError) {
$position: 0, logger.error(
$slice: 10, { projectId, mongoError },
}, 'failed to change project statues in mongo'
}, // only keep recent failures )
}, }
{ throw error
upsert: true,
},
_callback
)
} else { } else {
return db.projectHistoryFailures.deleteOne( try {
{ project_id: projectId }, await db.projectHistoryFailures.deleteOne({ project_id: projectId })
_callback } catch (mongoError) {
) logger.error(
{ projectId, mongoError },
'failed to change project statues in mongo'
)
}
return queueSize
} }
} }
export function setForceDebug(projectId, state, callback) { async function setForceDebug(projectId, state) {
if (state == null) { if (state == null) {
state = true state = true
} }
if (callback == null) {
callback = function () {}
}
logger.debug({ projectId, state }, 'setting forceDebug state for project') logger.debug({ projectId, state }, 'setting forceDebug state for project')
return db.projectHistoryFailures.updateOne( await db.projectHistoryFailures.updateOne(
{ project_id: projectId }, { project_id: projectId },
{ $set: { forceDebug: state } }, { $set: { forceDebug: state } },
{ upsert: true }, { upsert: true }
callback
) )
} }
// we only record the sync start time, and not the end time, because the // we only record the sync start time, and not the end time, because the
// record should be cleared on success. // record should be cleared on success.
export function recordSyncStart(projectId, callback) { async function recordSyncStart(projectId) {
if (callback == null) { await db.projectHistoryFailures.updateOne(
callback = function () {} { project_id: projectId },
}
return db.projectHistoryFailures.updateOne(
{ {
project_id: projectId, $currentDate: { resyncStartedAt: true },
}, $inc: { resyncAttempts: 1 },
{
$currentDate: {
resyncStartedAt: true,
},
$inc: {
resyncAttempts: 1,
},
$push: { $push: {
history: { history: {
$each: [{ resyncStartedAt: new Date() }], $each: [{ resyncStartedAt: new Date() }],
@ -109,207 +79,176 @@ export function recordSyncStart(projectId, callback) {
}, },
}, },
}, },
{ { upsert: true }
upsert: true,
},
callback
) )
} }
export function getFailureRecord(projectId, callback) { async function getFailureRecord(projectId) {
if (callback == null) { return await db.projectHistoryFailures.findOne({ project_id: projectId })
callback = function () {}
}
return db.projectHistoryFailures.findOne({ project_id: projectId }, callback)
} }
export function getLastFailure(projectId, callback) { async function getLastFailure(projectId) {
if (callback == null) { const result = await db.projectHistoryFailures.findOneAndUpdate(
callback = function () {}
}
return db.projectHistoryFailures.findOneAndUpdate(
{ project_id: projectId }, { project_id: projectId },
{ $inc: { requestCount: 1 } }, // increment the request count every time we check the last failure { $inc: { requestCount: 1 } }, // increment the request count every time we check the last failure
{ projection: { error: 1, ts: 1 } }, { projection: { error: 1, ts: 1 } }
(err, result) => callback(err, result && result.value)
) )
return result && result.value
} }
export function getFailedProjects(callback) { async function getFailedProjects() {
if (callback == null) { return await db.projectHistoryFailures.find({}).toArray()
callback = function () {}
}
return db.projectHistoryFailures.find({}).toArray(function (error, results) {
if (error != null) {
return callback(OError.tag(error))
}
return callback(null, results)
})
} }
export function getFailuresByType(callback) { async function getFailuresByType() {
if (callback == null) { const results = await db.projectHistoryFailures.find({}).toArray()
callback = function () {} const failureCounts = {}
const failureAttempts = {}
const failureRequests = {}
const maxQueueSize = {}
// count all the failures and number of attempts by type
for (const result of results || []) {
const failureType = result.error
const attempts = result.attempts || 1 // allow for field to be absent
const requests = result.requestCount || 0
const queueSize = result.queueSize || 0
if (failureCounts[failureType] > 0) {
failureCounts[failureType]++
failureAttempts[failureType] += attempts
failureRequests[failureType] += requests
maxQueueSize[failureType] = Math.max(queueSize, maxQueueSize[failureType])
} else {
failureCounts[failureType] = 1
failureAttempts[failureType] = attempts
failureRequests[failureType] = requests
maxQueueSize[failureType] = queueSize
}
} }
db.projectHistoryFailures.find({}).toArray(function (error, results) {
if (error != null) { return { failureCounts, failureAttempts, failureRequests, maxQueueSize }
return callback(OError.tag(error)) }
}
const failureCounts = {} async function getFailures() {
const failureAttempts = {} const { failureCounts, failureAttempts, failureRequests, maxQueueSize } =
const failureRequests = {} await getFailuresByType()
const maxQueueSize = {}
// count all the failures and number of attempts by type let attempts, failureType, label, requests
for (const result of Array.from(results || [])) { const shortNames = {
const failureType = result.error 'Error: bad response from filestore: 404': 'filestore-404',
const attempts = result.attempts || 1 // allow for field to be absent 'Error: bad response from filestore: 500': 'filestore-500',
const requests = result.requestCount || 0 'NotFoundError: got a 404 from web api': 'web-api-404',
const queueSize = result.queueSize || 0 'Error: history store a non-success status code: 413': 'history-store-413',
if (failureCounts[failureType] > 0) { 'Error: history store a non-success status code: 422': 'history-store-422',
failureCounts[failureType]++ 'Error: history store a non-success status code: 500': 'history-store-500',
failureAttempts[failureType] += attempts 'Error: history store a non-success status code: 503': 'history-store-503',
failureRequests[failureType] += requests 'Error: web returned a non-success status code: 500 (attempts: 2)':
maxQueueSize[failureType] = Math.max( 'web-500',
queueSize, 'Error: ESOCKETTIMEDOUT': 'socket-timeout',
maxQueueSize[failureType] 'Error: no project found': 'no-project-found',
) 'OpsOutOfOrderError: project structure version out of order on incoming updates':
} else { 'incoming-project-version-out-of-order',
failureCounts[failureType] = 1 'OpsOutOfOrderError: doc version out of order on incoming updates':
failureAttempts[failureType] = attempts 'incoming-doc-version-out-of-order',
failureRequests[failureType] = requests 'OpsOutOfOrderError: project structure version out of order':
maxQueueSize[failureType] = queueSize 'chunk-project-version-out-of-order',
} 'OpsOutOfOrderError: doc version out of order':
} 'chunk-doc-version-out-of-order',
return callback( 'Error: failed to extend lock': 'lock-overrun',
null, 'Error: tried to release timed out lock': 'lock-overrun',
failureCounts, 'Error: Timeout': 'lock-overrun',
failureAttempts, 'Error: sync ongoing': 'sync-ongoing',
failureRequests, 'SyncError: unexpected resyncProjectStructure update': 'sync-error',
maxQueueSize '[object Error]': 'unknown-error-object',
'UpdateWithUnknownFormatError: update with unknown format':
'unknown-format',
'Error: update with unknown format': 'unknown-format',
'TextOperationError: The base length of the second operation has to be the target length of the first operation':
'text-op-error',
'Error: ENOSPC: no space left on device, write': 'ENOSPC',
'*': 'other',
}
// set all the known errors to zero if not present (otherwise gauges stay on their last value)
const summaryCounts = {}
const summaryAttempts = {}
const summaryRequests = {}
const summaryMaxQueueSize = {}
for (failureType in shortNames) {
label = shortNames[failureType]
summaryCounts[label] = 0
summaryAttempts[label] = 0
summaryRequests[label] = 0
summaryMaxQueueSize[label] = 0
}
// record a metric for each type of failure
for (failureType in failureCounts) {
const failureCount = failureCounts[failureType]
label = shortNames[failureType] || shortNames['*']
summaryCounts[label] += failureCount
summaryAttempts[label] += failureAttempts[failureType]
summaryRequests[label] += failureRequests[failureType]
summaryMaxQueueSize[label] = Math.max(
maxQueueSize[failureType],
summaryMaxQueueSize[label]
) )
}) }
for (label in summaryCounts) {
const count = summaryCounts[label]
metrics.globalGauge('failed', count, 1, { status: label })
}
for (label in summaryAttempts) {
attempts = summaryAttempts[label]
metrics.globalGauge('attempts', attempts, 1, { status: label })
}
for (label in summaryRequests) {
requests = summaryRequests[label]
metrics.globalGauge('requests', requests, 1, { status: label })
}
for (label in summaryMaxQueueSize) {
const queueSize = summaryMaxQueueSize[label]
metrics.globalGauge('max-queue-size', queueSize, 1, { status: label })
}
return {
counts: summaryCounts,
attempts: summaryAttempts,
requests: summaryRequests,
maxQueueSize: summaryMaxQueueSize,
}
} }
export function getFailures(callback) { // EXPORTS
if (callback == null) {
callback = function () {}
}
return getFailuresByType(
function (
error,
failureCounts,
failureAttempts,
failureRequests,
maxQueueSize
) {
let attempts, failureType, label, requests
if (error != null) {
return callback(OError.tag(error))
}
const shortNames = { const getFailedProjectsCb = callbackify(getFailedProjects)
'Error: bad response from filestore: 404': 'filestore-404', const getFailureRecordCb = callbackify(getFailureRecord)
'Error: bad response from filestore: 500': 'filestore-500', const getFailuresCb = callbackify(getFailures)
'NotFoundError: got a 404 from web api': 'web-api-404', const getLastFailureCb = callbackify(getLastFailure)
'Error: history store a non-success status code: 413': const recordCb = callbackify(record)
'history-store-413', const recordSyncStartCb = callbackify(recordSyncStart)
'Error: history store a non-success status code: 422': const setForceDebugCb = callbackify(setForceDebug)
'history-store-422',
'Error: history store a non-success status code: 500':
'history-store-500',
'Error: history store a non-success status code: 503':
'history-store-503',
'Error: web returned a non-success status code: 500 (attempts: 2)':
'web-500',
'Error: ESOCKETTIMEDOUT': 'socket-timeout',
'Error: no project found': 'no-project-found',
'OpsOutOfOrderError: project structure version out of order on incoming updates':
'incoming-project-version-out-of-order',
'OpsOutOfOrderError: doc version out of order on incoming updates':
'incoming-doc-version-out-of-order',
'OpsOutOfOrderError: project structure version out of order':
'chunk-project-version-out-of-order',
'OpsOutOfOrderError: doc version out of order':
'chunk-doc-version-out-of-order',
'Error: failed to extend lock': 'lock-overrun',
'Error: tried to release timed out lock': 'lock-overrun',
'Error: Timeout': 'lock-overrun',
'Error: sync ongoing': 'sync-ongoing',
'SyncError: unexpected resyncProjectStructure update': 'sync-error',
'[object Error]': 'unknown-error-object',
'UpdateWithUnknownFormatError: update with unknown format':
'unknown-format',
'Error: update with unknown format': 'unknown-format',
'TextOperationError: The base length of the second operation has to be the target length of the first operation':
'text-op-error',
'Error: ENOSPC: no space left on device, write': 'ENOSPC',
'*': 'other',
}
// set all the known errors to zero if not present (otherwise gauges stay on their last value) export {
const summaryCounts = {} getFailedProjectsCb as getFailedProjects,
const summaryAttempts = {} getFailureRecordCb as getFailureRecord,
const summaryRequests = {} getLastFailureCb as getLastFailure,
const summaryMaxQueueSize = {} getFailuresCb as getFailures,
recordCb as record,
for (failureType in shortNames) { recordSyncStartCb as recordSyncStart,
label = shortNames[failureType] setForceDebugCb as setForceDebug,
summaryCounts[label] = 0
summaryAttempts[label] = 0
summaryRequests[label] = 0
summaryMaxQueueSize[label] = 0
}
// record a metric for each type of failure
for (failureType in failureCounts) {
const failureCount = failureCounts[failureType]
label = shortNames[failureType] || shortNames['*']
summaryCounts[label] += failureCount
summaryAttempts[label] += failureAttempts[failureType]
summaryRequests[label] += failureRequests[failureType]
summaryMaxQueueSize[label] = Math.max(
maxQueueSize[failureType],
summaryMaxQueueSize[label]
)
}
for (label in summaryCounts) {
const count = summaryCounts[label]
metrics.globalGauge('failed', count, 1, { status: label })
}
for (label in summaryAttempts) {
attempts = summaryAttempts[label]
metrics.globalGauge('attempts', attempts, 1, { status: label })
}
for (label in summaryRequests) {
requests = summaryRequests[label]
metrics.globalGauge('requests', requests, 1, { status: label })
}
for (label in summaryMaxQueueSize) {
const queueSize = summaryMaxQueueSize[label]
metrics.globalGauge('max-queue-size', queueSize, 1, { status: label })
}
return callback(null, {
counts: summaryCounts,
attempts: summaryAttempts,
requests: summaryRequests,
maxQueueSize: summaryMaxQueueSize,
})
}
)
} }
export const promises = { export const promises = {
getFailedProjects: promisify(getFailedProjects), getFailedProjects,
getFailureRecord: promisify(getFailureRecord), getFailureRecord,
getLastFailure: promisify(getLastFailure), getLastFailure,
getFailuresByType: promisify(getFailuresByType), getFailures,
getFailures: promisify(getFailures), record,
record: promisify(record), recordSyncStart,
recordSyncStart: promisify(recordSyncStart), setForceDebug,
setForceDebug: promisify(setForceDebug),
} }

View file

@ -1,16 +1,5 @@
/* eslint-disable
no-return-assign,
no-undef,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon' import sinon from 'sinon'
import { expect } from 'chai'
import { strict as esmock } from 'esmock' import { strict as esmock } from 'esmock'
import tk from 'timekeeper' import tk from 'timekeeper'
@ -20,11 +9,10 @@ describe('ErrorRecorder', function () {
beforeEach(async function () { beforeEach(async function () {
this.now = new Date() this.now = new Date()
tk.freeze(this.now) tk.freeze(this.now)
this.callback = sinon.stub()
this.db = { this.db = {
projectHistoryFailures: { projectHistoryFailures: {
deleteOne: sinon.stub().yields(), deleteOne: sinon.stub().resolves(),
updateOne: sinon.stub().yields(), updateOne: sinon.stub().resolves(),
}, },
} }
this.mongodb = { db: this.db } this.mongodb = { db: this.db }
@ -35,27 +23,28 @@ describe('ErrorRecorder', function () {
}) })
this.project_id = 'project-id-123' this.project_id = 'project-id-123'
return (this.queueSize = 445) this.queueSize = 445
}) })
afterEach(function () { afterEach(function () {
return tk.reset() tk.reset()
}) })
return describe('record', function () { describe('record', function () {
describe('with an error', function () { describe('with an error', function () {
beforeEach(function () { beforeEach(async function () {
this.error = new Error('something bad') this.error = new Error('something bad')
return this.ErrorRecorder.record( await expect(
this.project_id, this.ErrorRecorder.promises.record(
this.queueSize, this.project_id,
this.error, this.queueSize,
this.callback this.error
) )
).to.be.rejected
}) })
it('should record the error to mongo', function () { it('should record the error to mongo', function () {
return this.db.projectHistoryFailures.updateOne this.db.projectHistoryFailures.updateOne
.calledWithMatch( .calledWithMatch(
{ {
project_id: this.project_id, project_id: this.project_id,
@ -91,32 +80,25 @@ describe('ErrorRecorder', function () {
) )
.should.equal(true) .should.equal(true)
}) })
return it('should call the callback', function () {
return this.callback
.calledWith(this.error, this.queueSize)
.should.equal(true)
})
}) })
return describe('without an error', function () { describe('without an error', function () {
beforeEach(function () { beforeEach(async function () {
return this.ErrorRecorder.record( this.result = await this.ErrorRecorder.promises.record(
this.project_id, this.project_id,
this.queueSize, this.queueSize,
this.error, this.error
this.callback
) )
}) })
it('should remove any error from mongo', function () { it('should remove any error from mongo', function () {
return this.db.projectHistoryFailures.deleteOne this.db.projectHistoryFailures.deleteOne
.calledWithMatch({ project_id: this.project_id }) .calledWithMatch({ project_id: this.project_id })
.should.equal(true) .should.equal(true)
}) })
return it('should call the callback', function () { it('should return the queue size', function () {
return this.callback.calledWith(null, this.queueSize).should.equal(true) expect(this.result).to.equal(this.queueSize)
}) })
}) })
}) })