1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-02 01:17:58 +02:00

feat: Handle database connection errors with 500 (#725)

* feat: Handle database connection errors with 500

- If database goes away while unleash is running, unleash now stays
  running, but all api endpoints will return 500.
- This includes our health endpoint, which allows k8s or similar
  orchestrators to decide what should be done, rather than Unleash
  terminating unexpectedly
This commit is contained in:
Christopher Kolstad 2021-02-17 15:24:43 +01:00 committed by GitHub
parent f49b5084eb
commit 8bf4214ddb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 296 additions and 147 deletions

View File

@ -23,12 +23,15 @@ class ClientMetricsDb {
} }
async removeMetricsOlderThanOneHour() { async removeMetricsOlderThanOneHour() {
const rows = await this.db(TABLE) try {
.whereRaw("created_at < now() - interval '1 hour'") const rows = await this.db(TABLE)
.del(); .whereRaw("created_at < now() - interval '1 hour'")
.del();
if (rows > 0) { if (rows > 0) {
this.logger.debug(`Deleted ${rows} metrics`); this.logger.debug(`Deleted ${rows} metrics`);
}
} catch (e) {
this.logger.warn(`Error when deleting metrics ${e}`);
} }
} }
@ -39,26 +42,34 @@ class ClientMetricsDb {
// Used at startup to load all metrics last week into memory! // Used at startup to load all metrics last week into memory!
async getMetricsLastHour() { async getMetricsLastHour() {
const result = await this.db try {
.select(METRICS_COLUMNS) const result = await this.db
.from(TABLE) .select(METRICS_COLUMNS)
.limit(2000) .from(TABLE)
.whereRaw("created_at > now() - interval '1 hour'") .limit(2000)
.orderBy('created_at', 'asc'); .whereRaw("created_at > now() - interval '1 hour'")
.orderBy('created_at', 'asc');
return result.map(mapRow); return result.map(mapRow);
} catch (e) {
this.logger.warn(`error when getting metrics last hour ${e}`);
}
return [];
} }
// Used to poll for new metrics // Used to poll for new metrics
async getNewMetrics(lastKnownId) { async getNewMetrics(lastKnownId) {
const result = await this.db try {
.select(METRICS_COLUMNS) const res = await this.db
.from(TABLE) .select(METRICS_COLUMNS)
.limit(1000) .from(TABLE)
.where('id', '>', lastKnownId) .limit(1000)
.orderBy('created_at', 'asc'); .where('id', '>', lastKnownId)
.orderBy('created_at', 'asc');
return result.map(mapRow); return res.map(mapRow);
} catch (e) {
this.logger.warn(`error when getting new metrics ${e}`);
}
return [];
} }
destroy() { destroy() {

View File

@ -10,6 +10,7 @@ module.exports.createDb = function({ db, databaseSchema, getLogger }) {
connection: db, connection: db,
pool: db.pool, pool: db.pool,
searchPath: databaseSchema, searchPath: databaseSchema,
asyncStackTraces: true,
log: { log: {
debug: msg => logger.debug(msg), debug: msg => logger.debug(msg),
info: msg => logger.info(msg), info: msg => logger.info(msg),

View File

@ -13,48 +13,60 @@ const EVENT_COLUMNS = [
]; ];
class EventStore extends EventEmitter { class EventStore extends EventEmitter {
constructor(db) { constructor(db, getLogger) {
super(); super();
this.db = db; this.db = db;
this.logger = getLogger('lib/db/event-store.js');
} }
async store(event) { async store(event) {
await this.db('events').insert({ try {
type: event.type, await this.db('events').insert({
created_by: event.createdBy, // eslint-disable-line type: event.type,
data: event.data, created_by: event.createdBy, // eslint-disable-line
tags: event.tags ? JSON.stringify(event.tags) : [], data: event.data,
}); tags: event.tags ? JSON.stringify(event.tags) : [],
process.nextTick(() => this.emit(event.type, event)); });
process.nextTick(() => this.emit(event.type, event));
} catch (e) {
this.logger.warn(`Failed to store event ${e}`);
}
} }
async getEvents() { async getEvents() {
const rows = await this.db try {
.select(EVENT_COLUMNS) const rows = await this.db
.from('events') .select(EVENT_COLUMNS)
.limit(100) .from('events')
.orderBy('created_at', 'desc'); .limit(100)
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent); return rows.map(this.rowToEvent);
} catch (err) {
return [];
}
} }
async getEventsFilterByName(name) { async getEventsFilterByName(name) {
const rows = await this.db try {
.select(EVENT_COLUMNS) const rows = await this.db
.from('events') .select(EVENT_COLUMNS)
.limit(100) .from('events')
.whereRaw("data ->> 'name' = ?", [name]) .limit(100)
.andWhere( .whereRaw("data ->> 'name' = ?", [name])
'id', .andWhere(
'>=', 'id',
this.db '>=',
.select(this.db.raw('coalesce(max(id),0) as id')) this.db
.from('events') .select(this.db.raw('coalesce(max(id),0) as id'))
.where({ type: DROP_FEATURES }), .from('events')
) .where({ type: DROP_FEATURES }),
.orderBy('created_at', 'desc'); )
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent); return rows.map(this.rowToEvent);
} catch (err) {
return [];
}
} }
rowToEvent(row) { rowToEvent(row) {

View File

@ -57,7 +57,13 @@ class MetricsMonitor {
async function collectFeatureToggleMetrics() { async function collectFeatureToggleMetrics() {
featureTogglesTotal.reset(); featureTogglesTotal.reset();
const togglesCount = await featureToggleStore.count(); let togglesCount;
try {
togglesCount = await featureToggleStore.count();
// eslint-disable-next-line no-empty
} catch (e) {}
togglesCount = togglesCount || 0;
featureTogglesTotal.labels(version).set(togglesCount); featureTogglesTotal.labels(version).set(togglesCount);
} }
@ -105,14 +111,14 @@ class MetricsMonitor {
} }
}); });
this.configureDbMetrics(stores, eventStore); this.configureDbMetrics(stores, eventBus);
} }
stopMonitoring() { stopMonitoring() {
clearInterval(this.timer); clearInterval(this.timer);
} }
configureDbMetrics(stores, eventStore) { configureDbMetrics(stores, eventBus) {
if (stores.db && stores.db.client) { if (stores.db && stores.db.client) {
const dbPoolMin = new client.Gauge({ const dbPoolMin = new client.Gauge({
name: 'db_pool_min', name: 'db_pool_min',
@ -143,29 +149,31 @@ class MetricsMonitor {
'how many acquires are waiting for a resource to be released in DB pool', 'how many acquires are waiting for a resource to be released in DB pool',
}); });
eventStore.on(DB_POOL_UPDATE, data => { eventBus.on(DB_POOL_UPDATE, data => {
dbPoolFree.set(data.free); dbPoolFree.set(data.free);
dbPoolUsed.set(data.used); dbPoolUsed.set(data.used);
dbPoolPendingCreates.set(data.pendingCreates); dbPoolPendingCreates.set(data.pendingCreates);
dbPoolPendingAcquires.set(data.pendingAcquires); dbPoolPendingAcquires.set(data.pendingAcquires);
}); });
this.registerPoolMetrics(stores.db.client.pool, eventStore); this.registerPoolMetrics(stores.db.client.pool, eventBus);
setInterval( setInterval(
() => () => this.registerPoolMetrics(stores.db.client.pool, eventBus),
this.registerPoolMetrics(stores.db.client.pool, eventStore),
ONE_MINUTE, ONE_MINUTE,
); );
} }
} }
registerPoolMetrics(pool, eventStore) { registerPoolMetrics(pool, eventBus) {
eventStore.emit(DB_POOL_UPDATE, { try {
used: pool.numUsed(), eventBus.emit(DB_POOL_UPDATE, {
free: pool.numFree(), used: pool.numUsed(),
pendingCreates: pool.numPendingCreates(), free: pool.numFree(),
pendingAcquires: pool.numPendingAcquires(), pendingCreates: pool.numPendingCreates(),
}); pendingAcquires: pool.numPendingAcquires(),
});
// eslint-disable-next-line no-empty
} catch (e) {}
} }
} }

View File

@ -15,7 +15,7 @@ const monitor = createMetricsMonitor();
test.before(() => { test.before(() => {
const featureToggleStore = { const featureToggleStore = {
count: () => 123, count: async () => 123,
}; };
const config = { const config = {
serverMetrics: true, serverMetrics: true,

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import { handleErrors } from './util';
const Controller = require('../controller'); const Controller = require('../controller');
const extractUser = require('../../extract-user'); const extractUser = require('../../extract-user');
@ -16,8 +18,12 @@ class ArchiveController extends Controller {
} }
async getArchivedFeatures(req, res) { async getArchivedFeatures(req, res) {
const features = await this.featureService.getArchivedFeatures(); try {
res.json({ features }); const features = await this.featureService.getArchivedFeatures();
res.json({ features });
} catch (err) {
handleErrors(res, this.logger, err);
}
} }
async reviveFeatureToggle(req, res) { async reviveFeatureToggle(req, res) {

View File

@ -34,10 +34,14 @@ class ContextController extends Controller {
} }
async getContextFields(req, res) { async getContextFields(req, res) {
const fields = await this.contextService.getAll(); try {
res.status(200) const fields = await this.contextService.getAll();
.json(fields) res.status(200)
.end(); .json(fields)
.end();
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getContextField(req, res) { async getContextField(req, res) {

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import { handleErrors } from './util';
const Controller = require('../controller'); const Controller = require('../controller');
const eventDiffer = require('../../event-differ'); const eventDiffer = require('../../event-differ');
@ -15,20 +17,30 @@ class EventController extends Controller {
} }
async getEvents(req, res) { async getEvents(req, res) {
const events = await this.eventStore.getEvents(); try {
eventDiffer.addDiffs(events); const events = await this.eventStore.getEvents();
res.json({ version, events }); eventDiffer.addDiffs(events);
res.json({ version, events });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getEventsForToggle(req, res) { async getEventsForToggle(req, res) {
const toggleName = req.params.name; const toggleName = req.params.name;
const events = await this.eventStore.getEventsFilterByName(toggleName); try {
const events = await this.eventStore.getEventsFilterByName(
toggleName,
);
if (events) { if (events) {
eventDiffer.addDiffs(events); eventDiffer.addDiffs(events);
res.json({ toggleName, events }); res.json({ toggleName, events });
} else { } else {
res.status(404).json({ error: 'Could not find events' }); res.status(404).json({ error: 'Could not find events' });
}
} catch (e) {
handleErrors(res, this.logger, e);
} }
} }
} }

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import { handleErrors } from './util';
const Controller = require('../controller'); const Controller = require('../controller');
const version = 1; const version = 1;
@ -14,8 +16,12 @@ class FeatureTypeController extends Controller {
} }
async getAllFeatureTypes(req, res) { async getAllFeatureTypes(req, res) {
const types = await this.featureTypeStore.getAll(); try {
res.json({ version, types }); const types = await this.featureTypeStore.getAll();
res.json({ version, types });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
} }

View File

@ -49,11 +49,15 @@ class FeatureController extends Controller {
} }
async getAllToggles(req, res) { async getAllToggles(req, res) {
const features = await this.featureService.getFeatures( try {
req.query, const features = await this.featureService.getFeatures(
fields, req.query,
); fields,
res.json({ version, features }); );
res.json({ version, features });
} catch (err) {
handleErrors(res, this.logger, err);
}
} }
async getToggle(req, res) { async getToggle(req, res) {
@ -67,8 +71,14 @@ class FeatureController extends Controller {
} }
async listTags(req, res) { async listTags(req, res) {
const tags = await this.featureService.listTags(req.params.featureName); try {
res.json({ version, tags }); const tags = await this.featureService.listTags(
req.params.featureName,
);
res.json({ version, tags });
} catch (err) {
handleErrors(res, this.logger, err);
}
} }
async addTag(req, res) { async addTag(req, res) {
@ -89,12 +99,16 @@ class FeatureController extends Controller {
async removeTag(req, res) { async removeTag(req, res) {
const { featureName, type, value } = req.params; const { featureName, type, value } = req.params;
const userName = extractUser(req); const userName = extractUser(req);
await this.featureService.removeTag( try {
featureName, await this.featureService.removeTag(
{ type, value }, featureName,
userName, { type, value },
); userName,
res.status(200).end(); );
res.status(200).end();
} catch (err) {
handleErrors(res, this.logger, err);
}
} }
async validate(req, res) { async validate(req, res) {

View File

@ -16,9 +16,9 @@ const {
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup(databaseIsUp = true) {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores(); const stores = store.createStores(databaseIsUp);
const perms = permissions(); const perms = permissions();
const config = { const config = {
baseUriPath: base, baseUriPath: base,
@ -614,3 +614,9 @@ test('Tags should be included in updated events', async t => {
t.is(events[0].tags[0].type, 'simple'); t.is(events[0].tags[0].type, 'simple');
t.is(events[0].tags[0].value, 'tag'); t.is(events[0].tags[0].value, 'tag');
}); });
test('Trying to get features while database is down should yield 500', t => {
t.plan(0);
const { request, base } = getSetup(false);
return request.get(`${base}/api/admin/features`).expect(500);
});

View File

@ -28,29 +28,45 @@ class MetricsController extends Controller {
} }
async getSeenToggles(req, res) { async getSeenToggles(req, res) {
const seenAppToggles = await this.metrics.getAppsWithToggles(); try {
res.json(seenAppToggles); const seenAppToggles = await this.metrics.getAppsWithToggles();
res.json(seenAppToggles);
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getSeenApps(req, res) { async getSeenApps(req, res) {
const seenApps = await this.metrics.getSeenApps(); try {
res.json(seenApps); const seenApps = await this.metrics.getSeenApps();
res.json(seenApps);
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getFeatureToggles(req, res) { async getFeatureToggles(req, res) {
const toggles = await this.metrics.getTogglesMetrics(); try {
res.json(toggles); const toggles = await this.metrics.getTogglesMetrics();
res.json(toggles);
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getFeatureToggle(req, res) { async getFeatureToggle(req, res) {
const { name } = req.params; try {
const data = await this.metrics.getTogglesMetrics(); const { name } = req.params;
const lastHour = data.lastHour[name] || {}; const data = await this.metrics.getTogglesMetrics();
const lastMinute = data.lastMinute[name] || {}; const lastHour = data.lastHour[name] || {};
res.json({ const lastMinute = data.lastMinute[name] || {};
lastHour, res.json({
lastMinute, lastHour,
}); lastMinute,
});
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async deleteApplication(req, res) { async deleteApplication(req, res) {

View File

@ -36,8 +36,12 @@ class StrategyController extends Controller {
} }
async getAllStratgies(req, res) { async getAllStratgies(req, res) {
const strategies = await this.strategyService.getStrategies(); try {
res.json({ version, strategies }); const strategies = await this.strategyService.getStrategies();
res.json({ version, strategies });
} catch (err) {
handleErrors(res, this.logger, err);
}
} }
async getStrategy(req, res) { async getStrategy(req, res) {

View File

@ -16,10 +16,10 @@ const {
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup(databaseIsUp = true) {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const perms = permissions(); const perms = permissions();
const stores = store.createStores(); const stores = store.createStores(databaseIsUp);
const config = { const config = {
baseUriPath: base, baseUriPath: base,
stores, stores,
@ -261,3 +261,9 @@ test(`deprecating 'default' strategy will yield 403`, t => {
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(403); .expect(403);
}); });
test('Getting strategies while database is down should yield 500', t => {
t.plan(0);
const { request, base } = getSetup(false);
return request.get(`${base}/api/admin/strategies`).expect(500);
});

View File

@ -22,8 +22,12 @@ class TagTypeController extends Controller {
} }
async getTagTypes(req, res) { async getTagTypes(req, res) {
const tagTypes = await this.tagTypeService.getAll(); try {
res.json({ version, tagTypes }); const tagTypes = await this.tagTypeService.getAll();
res.json({ version, tagTypes });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async validate(req, res) { async validate(req, res) {

View File

@ -22,13 +22,21 @@ class TagController extends Controller {
} }
async getTags(req, res) { async getTags(req, res) {
const tags = await this.tagService.getTags(); try {
res.json({ version, tags }); const tags = await this.tagService.getTags();
res.json({ version, tags });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getTagsByType(req, res) { async getTagsByType(req, res) {
const tags = await this.tagService.getTagsByType(req.params.type); try {
res.json({ version, tags }); const tags = await this.tagService.getTagsByType(req.params.type);
res.json({ version, tags });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getTag(req, res) { async getTag(req, res) {

View File

@ -12,9 +12,9 @@ const { UPDATE_FEATURE } = require('../../permissions');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup(databaseIsUp = true) {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores(); const stores = store.createStores(databaseIsUp);
const perms = permissions(); const perms = permissions();
const config = { const config = {
baseUriPath: base, baseUriPath: base,
@ -118,3 +118,9 @@ test('should be able to filter by type', t => {
t.is(res.body.tags[0].value, 'TeamRed'); t.is(res.body.tags[0].value, 'TeamRed');
}); });
}); });
test('Getting tags while database is down should be a 500', t => {
t.plan(0);
const { request, base } = getSetup(false);
return request.get(`${base}/api/admin/tags`).expect(500);
});

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import { handleErrors } from '../admin-api/util';
const Controller = require('../controller'); const Controller = require('../controller');
const version = 1; const version = 1;
@ -23,11 +25,15 @@ class FeatureController extends Controller {
} }
async getAll(req, res) { async getAll(req, res) {
const features = await this.toggleService.getFeatures( try {
req.query, const features = await this.toggleService.getFeatures(
FEATURE_COLUMNS_CLIENT, req.query,
); FEATURE_COLUMNS_CLIENT,
res.json({ version, features }); );
res.json({ version, features });
} catch (e) {
handleErrors(res, this.logger, e);
}
} }
async getFeatureToggle(req, res) { async getFeatureToggle(req, res) {

View File

@ -1,12 +1,15 @@
'use strict'; 'use strict';
module.exports = () => { module.exports = (databaseIsUp = true) => {
const _features = []; const _features = [];
const _archive = []; const _archive = [];
const _featureTags = {}; const _featureTags = {};
return { return {
getFeature: name => { getFeature: name => {
if (!databaseIsUp) {
return Promise.reject(new Error('No database connection'));
}
const toggle = _features.find(f => f.name === name); const toggle = _features.find(f => f.name === name);
if (toggle) { if (toggle) {
return Promise.resolve(toggle); return Promise.resolve(toggle);
@ -63,6 +66,9 @@ module.exports = () => {
}, },
importFeature: feat => Promise.resolve(_features.push(feat)), importFeature: feat => Promise.resolve(_features.push(feat)),
getFeatures: query => { getFeatures: query => {
if (!databaseIsUp) {
return Promise.reject(new Error('No database connection'));
}
if (query) { if (query) {
const activeQueryKeys = Object.keys(query).filter( const activeQueryKeys = Object.keys(query).filter(
t => query[t], t => query[t],

View File

@ -2,13 +2,18 @@
const NotFoundError = require('../../lib/error/notfound-error'); const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => { module.exports = (databaseIsUp = true) => {
const _strategies = [ const _strategies = [
{ name: 'default', editable: false, parameters: {}, deprecated: false }, { name: 'default', editable: false, parameters: {}, deprecated: false },
]; ];
return { return {
getStrategies: () => Promise.resolve(_strategies), getStrategies: () => {
if (databaseIsUp) {
return Promise.resolve(_strategies);
}
return Promise.reject(new Error('No database connection'));
},
getEditableStrategies: () => getEditableStrategies: () =>
Promise.resolve(_strategies.filter(s => s.editable)), Promise.resolve(_strategies.filter(s => s.editable)),
getStrategy: name => { getStrategy: name => {

View File

@ -1,9 +1,12 @@
const NotFoundError = require('../../lib/error/notfound-error'); const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => { module.exports = (databaseIsUp = true) => {
const _tags = []; const _tags = [];
return { return {
getTagsByType: type => { getTagsByType: type => {
if (!databaseIsUp) {
return Promise.reject(new Error('No database connection'));
}
const tags = _tags.filter(t => t.type === type); const tags = _tags.filter(t => t.type === type);
return Promise.resolve(tags); return Promise.resolve(tags);
}, },
@ -18,7 +21,12 @@ module.exports = () => {
1, 1,
); );
}, },
getAll: () => Promise.resolve(_tags), getAll: () => {
if (!databaseIsUp) {
return Promise.reject(new Error('No database connection'));
}
return Promise.resolve(_tags);
},
getTag: (type, value) => { getTag: (type, value) => {
const tag = _tags.find(t => t.type === type && t.value === value); const tag = _tags.find(t => t.type === type && t.value === value);
if (tag) { if (tag) {

View File

@ -13,7 +13,7 @@ const settingStore = require('./fake-setting-store');
const addonStore = require('./fake-addon-store'); const addonStore = require('./fake-addon-store');
module.exports = { module.exports = {
createStores: () => { createStores: (databaseIsUp = true) => {
const db = { const db = {
select: () => ({ select: () => ({
from: () => Promise.resolve(), from: () => Promise.resolve(),
@ -22,17 +22,17 @@ module.exports = {
return { return {
db, db,
clientApplicationsStore: clientApplicationsStore(), clientApplicationsStore: clientApplicationsStore(databaseIsUp),
clientMetricsStore: new ClientMetricsStore(), clientMetricsStore: new ClientMetricsStore(databaseIsUp),
clientInstanceStore: clientInstanceStore(), clientInstanceStore: clientInstanceStore(databaseIsUp),
featureToggleStore: featureToggleStore(), featureToggleStore: featureToggleStore(databaseIsUp),
tagStore: tagStore(), tagStore: tagStore(databaseIsUp),
tagTypeStore: tagTypeStore(), tagTypeStore: tagTypeStore(databaseIsUp),
eventStore: new EventStore(), eventStore: new EventStore(databaseIsUp),
strategyStore: strategyStore(), strategyStore: strategyStore(databaseIsUp),
contextFieldStore: contextFieldStore(), contextFieldStore: contextFieldStore(databaseIsUp),
settingStore: settingStore(), settingStore: settingStore(databaseIsUp),
addonStore: addonStore(), addonStore: addonStore(databaseIsUp),
}; };
}, },
}; };